diff --git a/backend/muse/alerts.py b/backend/muse/alerts.py index 6a8b4e2..2d30133 100644 --- a/backend/muse/alerts.py +++ b/backend/muse/alerts.py @@ -18,7 +18,10 @@ from datetime import datetime, timedelta, timezone from typing import Optional +from types import SimpleNamespace + from . import db +from .autopilot import sessions as live_discovery from .incremental import new_objects from .models import AlertEvent from .paths import SessionPaths @@ -29,6 +32,14 @@ _USAGE_WARM_INTERVAL = 60.0 # mtime-cached, so warm calls only parse changed files _AI_SCHEDULE_INTERVAL = 900.0 # auto-digest check cadence (opt-in via MUSE_AI_AUTO_DIGEST) +# Hook-driven alerts (see routers/hooks.py). A Stop hook fires at the end of +# EVERY turn — including rapid back-and-forth at the keyboard — so the alert +# waits this long and re-checks the session is still idle before pushing. +_TURN_END_SETTLE_SECONDS = 6.0 +# After a hook-driven alert, suppress the polling tick's duplicate for this long +# (the tick reads a TTL-cached snapshot and would re-fire on the same transition). +_HOOK_SUPPRESS_SECONDS = 90.0 + def _scan_errors(objs: list[dict]) -> list[str]: """Short descriptions of any error indicators in a batch of raw lines.""" @@ -66,6 +77,11 @@ def __init__(self, service) -> None: self._last_checkpoint = 0.0 self._last_usage_warm = 0.0 self._last_ai_schedule = 0.0 + # Hook-driven alert state: tick-suppression windows + a per-session + # generation counter that cancels an in-flight turn-ended alert when the + # user replies (or another Stop supersedes it). + self._suppress: dict[tuple[str, str], float] = {} + self._turn_gen: dict[str, int] = {} def start(self) -> None: if self._task is None or self._task.done(): @@ -184,9 +200,9 @@ async def _tick(self) -> None: self._states[sid] = s.state if first or prev is None or prev == s.state: continue - if s.state == "waiting" and rules.on_waiting: + if s.state == "waiting" and rules.on_waiting and not self._suppressed(sid, "waiting"): await self._fire(cfg, rules, s, "waiting", f"✋ {s.title} is waiting for you", "") - elif s.state == "stopped" and rules.on_stopped: + elif s.state == "stopped" and rules.on_stopped and not self._suppressed(sid, "stopped"): await self._fire(cfg, rules, s, "stopped", f"⏹ {s.title} stopped", "") # Forget sessions that disappeared. @@ -197,6 +213,89 @@ async def _tick(self) -> None: self._primed = True + # --- hook-driven alerts --------------------------------------------------- + # Claude Code hooks (routers/hooks.py) call these on the event loop for + # instant notifications; the polling tick above stays as the fallback for + # sessions without hooks installed. Each hook alert marks the session state + # and opens a suppression window so the tick can't double-fire. + + def _suppressed(self, sid: str, kind: str) -> bool: + return time.monotonic() < self._suppress.get((sid, kind), 0.0) + + def _mark_hook_alert(self, sid: str, kind: str, state: str) -> None: + self._states[sid] = state + self._suppress[(sid, kind)] = time.monotonic() + _HOOK_SUPPRESS_SECONDS + + def _summary_for(self, sid: str): + try: + for s in self.service.list_sessions(): + if s.session_id == sid: + return s + except Exception: + pass + return SimpleNamespace(session_id=sid, title=sid[:8]) + + def hook_user_replied(self, sid: str) -> None: + """UserPromptSubmit: the user answered — cancel any in-flight turn-ended + alert and clear the waiting state instantly.""" + self._turn_gen[sid] = self._turn_gen.get(sid, 0) + 1 + if self._states.get(sid) == "waiting": + self._states[sid] = "running" + + async def hook_needs_you(self, sid: str, message: str) -> None: + """Notification hook: Claude needs permission (or sat idle). Always + actionable — push immediately.""" + rules = self.service.get_alert_rules() + if not rules.on_waiting: + return + if self._states.get(sid) == "waiting" and self._suppressed(sid, "waiting"): + return # already alerted for this pause + s = self._summary_for(sid) + self._mark_hook_alert(sid, "waiting", "waiting") + await self._fire( + self.service.get_notify_config(), rules, s, + "waiting", f"✋ {s.title} needs you", message[:140], + ) + + async def hook_turn_ended(self, sid: str) -> None: + """Stop hook (with nothing queued to deliver): the turn ended. Wait a + settle period, then push only if the session is still idle — so rapid + at-the-keyboard back-and-forth doesn't spam the phone.""" + rules = self.service.get_alert_rules() + if not rules.on_waiting: + return + gen = self._turn_gen.get(sid, 0) + 1 + self._turn_gen[sid] = gen + await asyncio.sleep(_TURN_END_SETTLE_SECONDS) + if self._turn_gen.get(sid) != gen: + return # user replied (or a newer turn superseded this one) + if self._states.get(sid) == "waiting" and self._suppressed(sid, "waiting"): + return + ls = await asyncio.to_thread( + lambda: next((x for x in live_discovery.discover() if x.session_id == sid), None) + ) + # Unknown to live discovery (headless/one-off run) → not ours to alert on; + # moved on / blocked on a permission → Notification or the tick covers it. + if ls is None or ls.status != "idle" or ls.waiting_for: + return + s = self._summary_for(sid) + self._mark_hook_alert(sid, "waiting", "waiting") + await self._fire( + self.service.get_notify_config(), rules, s, + "waiting", f"✋ {s.title} is ready for you", "", + ) + + async def hook_session_end(self, sid: str) -> None: + rules = self.service.get_alert_rules() + if not rules.on_stopped or self._states.get(sid) == "stopped": + return + s = self._summary_for(sid) + self._mark_hook_alert(sid, "stopped", "stopped") + await self._fire( + self.service.get_notify_config(), rules, s, + "stopped", f"⏹ {s.title} ended", "", + ) + def _schedule_ai_digests(self) -> None: """Enqueue yesterday's daily digest (and, on Mondays, last week's retro) when enabled and not already generated/pending. Runs in a worker thread.""" diff --git a/backend/muse/autopilot/controller.py b/backend/muse/autopilot/controller.py index a950e1b..d7996d1 100644 --- a/backend/muse/autopilot/controller.py +++ b/backend/muse/autopilot/controller.py @@ -5,22 +5,34 @@ from __future__ import annotations import asyncio +import json import re import time from datetime import datetime, timedelta, timezone from typing import Optional from .. import discovery as session_discovery +from .. import options as opt from ..config import get_settings from ..models import AutopilotConfig, AutopilotSession, AutopilotState from ..usage_cache import context_pcts, scan_all from . import sessions as live_discovery +from . import snapshot as layout_snapshot from . import tmux from .resettime import parse_reset_time from .store import AutopilotStore TICK_SECONDS = 5 +# Layout snapshots for session-restore: check the topology at most this often, and only +# write when the structure changed (or a long backstop elapsed) — change-driven, so it +# doesn't churn while you're just working inside a session. +SNAPSHOT_CHECK_SECONDS = 25 +SNAPSHOT_BACKSTOP_SECONDS = 1800 INJECT_STATUSES = {"idle"} # only when a turn finished and it's awaiting the user +# Minimum gap between queued-reply deliveries to the same session: after a send +# the status file takes a moment to flip to busy, so without a floor the next +# tick could double-fire into the same idle turn. +QUEUE_COOLDOWN_SECONDS = 15 def _dt_or_none(value) -> Optional[datetime]: @@ -49,6 +61,10 @@ def __init__(self) -> None: # Optional observer for parsed usage-limit reset times (wired to the # usage-history store in main.py so stats can anchor the 5h window). self.on_reset = None + # Optional observer for limit-hit sightings: on_limit("5h"|"week") records + # the window's spend as an observed ceiling (subscription plans publish no + # $ caps, so the wall's location is only learnable by hitting it). + self.on_limit = None # AI idle-mode callables (wired in main.py to the SessionService — same # pattern as on_reset, avoiding an import cycle): # enqueue_draft(sid) -> AIJob | None @@ -57,6 +73,11 @@ def __init__(self) -> None: self.enqueue_draft = None self.get_ai_job = None self.ai_cost_today = None + # Last queued-reply delivery per session (monotonic secs), for the cooldown. + self._queue_sent_at: dict[str, float] = {} + # Layout-snapshot bookkeeping (monotonic secs): when we last checked/wrote. + self._last_snapshot_check = 0.0 + self._last_snapshot_write = 0.0 def start(self) -> None: if self._task is None or self._task.done(): @@ -172,7 +193,138 @@ async def _run(self) -> None: # board ticker). _context_pcts = staticmethod(context_pcts) + # --- queued replies ------------------------------------------------------- + # Deliver user-authored "send when this turn ends" messages. This runs inside + # the controller because the controller is the one disciplined automated + # tmux-write site — same guards as autopilot injection (idle status, pane + # re-check, rate-limit banner, visible-menu refusal), but it does NOT require + # autopilot to be armed: queueing was an explicit user action. + + def _queue_block_reason( + self, sid: str, live: dict, now_mono: float, *, force: bool = False + ) -> Optional[str]: + """Why the next queued reply for `sid` can't be delivered right now, or + None if it would go. `force` (a user's explicit "send now") skips the + wait-for-idle gate but never overrides the guards that would corrupt the + session — typing into an open menu or over a usage-limit banner.""" + ls = live.get(sid) + if ls is None or not ls.pane_id: + return "session isn’t running in tmux" + if not force: + if ls.waiting_for: + return f"waiting for {ls.waiting_for}" + if ls.status not in INJECT_STATUSES: + return f"session is {ls.status}, not idle" + if now_mono - self._queue_sent_at.get(sid, 0.0) < QUEUE_COOLDOWN_SECONDS: + return "just delivered — cooling down" + pane = tmux.capture_pane(ls.pane_id, 40) + if _RATE_LIMIT_RE.search(pane): + self._observe_limit(pane) + return "usage-limit banner on screen" + # A visible ❯ menu means typed text would land IN the dialog (digits + # select options!) — never deliver over one, even when forced. + if opt.parse_permission_menu(pane) is not None: + return "a menu is open — answer it first" + return None + + def _send_batch(self, sid: str, pane_id: str, now_mono: float) -> tuple[list[int], Optional[str]]: + """Deliver the oldest pending item plus any directly-following 'append' + items (they asked to share one message). Returns (sent ids, error).""" + batch = self.store.queue_next_batch(sid) + if not batch: + return [], None + combined = "\n".join(i.text for i in batch) + if "\n" in combined: + # Multiline goes as a bracketed paste — raw LF via send-keys would + # submit each line as its own message. + ok, err = tmux.paste_text(pane_id, combined) + else: + ok, err = tmux.send_text(pane_id, combined) + if ok: + self._queue_sent_at[sid] = now_mono + for i in batch: + self.store.queue_mark(i.id, "sent") + self.store.log( + sid, "queue_sent", + f"{pane_id} ← {combined[:80]}" + + (f" ({len(batch)} items)" if len(batch) > 1 else ""), + ) + return [i.id for i in batch], None + for i in batch: + self.store.queue_mark(i.id, "failed", error=err) + self.store.log(sid, "error", f"queued send failed: {err}") + return [], err + + def deliver_queued(self, session_id: Optional[str] = None) -> list[int]: + """Try to deliver the next queued reply for `session_id` (or for every + session with a pending queue). Returns the queue ids actually sent. + Safe to call from any thread; each delivery re-checks the world fresh.""" + counts = self.store.queue_counts() + if session_id is not None: + counts = {session_id: counts[session_id]} if session_id in counts else {} + if not counts: + return [] + live = {s.session_id: s for s in live_discovery.discover()} + now_mono = time.monotonic() + sent: list[int] = [] + for sid in counts: + if self._queue_block_reason(sid, live, now_mono) is not None: + continue + ids, _ = self._send_batch(sid, live[sid].pane_id, now_mono) + sent.extend(ids) + return sent + + def deliver_now(self, session_id: str) -> tuple[list[int], Optional[str]]: + """User override: deliver the next batch immediately, skipping the + wait-for-idle gate but not the menu / rate-limit guards. Returns + (sent ids, reason-it-was-blocked).""" + if session_id not in self.store.queue_counts(): + return [], "nothing queued" + live = {s.session_id: s for s in live_discovery.discover()} + now_mono = time.monotonic() + reason = self._queue_block_reason(session_id, live, now_mono, force=True) + if reason is not None: + return [], reason + return self._send_batch(session_id, live[session_id].pane_id, now_mono) + + def queue_hold_reason(self, session_id: str) -> Optional[str]: + """Why this session's pending queue isn't delivering (for the UI), or + None if it has no pending items or would deliver on the next tick.""" + if session_id not in self.store.queue_counts(): + return None + live = {s.session_id: s for s in live_discovery.discover()} + return self._queue_block_reason(session_id, live, time.monotonic()) + + def _maybe_snapshot(self) -> None: + """Capture the tmux topology for session-restore, throttled and change-driven. + Runs regardless of arming (it's independent of message injection).""" + now = time.monotonic() + if now - self._last_snapshot_check < SNAPSHOT_CHECK_SECONDS: + return + self._last_snapshot_check = now + snap = layout_snapshot.build_snapshot() + if snap is None: # tmux down or no Claude windows → keep the last-known-good + return + sig = layout_snapshot.topology_sig(snap) + latest = self.store.latest_snapshot() + unchanged = latest is not None and latest[0] == sig + if unchanged and (now - self._last_snapshot_write) < SNAPSHOT_BACKSTOP_SECONDS: + return + self.store.save_snapshot(json.dumps(snap), sig) + self._last_snapshot_write = now + def _tick(self) -> None: + # Session-restore snapshot — independent of arming; never let it break the loop. + try: + self._maybe_snapshot() + except Exception: + pass + # Queued replies deliver regardless of arming/schedule — an explicit user + # "send this when ready" beats the autopilot on/off switch. + try: + self.deliver_queued() + except Exception: + pass if not self.store.is_armed() or not self._within_hours(): return configs = self.store.all_configs() @@ -212,12 +364,7 @@ def _tick(self) -> None: # Usage-limit back-off: peek at the pane before acting. pane = tmux.capture_pane(ls.pane_id, 40) if _RATE_LIMIT_RE.search(pane): - reset = parse_reset_time(pane, now) - if reset and self.on_reset: - try: - self.on_reset(reset) # anchor stats' 5h window - except Exception: - pass + reset = self._observe_limit(pane, now) until = reset if (reset and reset > now) else now + timedelta(seconds=cfg.backoff_seconds) self.store.set_backoff(sid, until) when = until.astimezone().strftime("%a %H:%M") @@ -256,6 +403,23 @@ def _tick(self) -> None: else: self.store.log(sid, "error", err) + def _observe_limit(self, pane_text: str, now: Optional[datetime] = None): + """A rate-limit banner is on screen: report the parsed reset time (anchors + stats' 5h window) and the hit itself (calibrates the observed ceiling). + Returns the parsed reset time, if any.""" + reset = parse_reset_time(pane_text, now or datetime.now(timezone.utc)) + if reset and self.on_reset: + try: + self.on_reset(reset) + except Exception: + pass + if self.on_limit: + try: + self.on_limit("week" if "week" in pane_text.lower() else "5h") + except Exception: + pass + return reset + # --- AI idle mode (two-phase) --------------------------------------------- # Phase A requests a draft at exactly the point message-mode would inject # (so every existing gate — enabled/idle/no-waiting_for/max_sends/interval/ diff --git a/backend/muse/autopilot/snapshot.py b/backend/muse/autopilot/snapshot.py new file mode 100644 index 0000000..0d7e5b4 --- /dev/null +++ b/backend/muse/autopilot/snapshot.py @@ -0,0 +1,178 @@ +"""Snapshot the tmux topology so it can be rebuilt after a reboot ("session restore"). + +tmux (sessions/windows/panes) is wiped on reboot, but Claude Code conversations persist on +disk (~/.claude/projects//.jsonl) and resume with +`claude --resume `. We periodically capture the structure — which Claude sessions exist, +how they're grouped, their cwds/names — and on demand recreate the windows, resuming each +Claude where it left off. Window granularity only (v1 doesn't restore intra-window splits). +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shlex +from datetime import datetime, timezone + +from . import sessions as live_discovery +from . import tmux + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _rep_pane(panes: list[dict], pane_sid: dict[str, str]) -> dict: + """The pane that best represents a window: the Claude pane (one with a resolved + session id) if any, else the active pane, else the lowest-index pane.""" + return ( + next((p for p in panes if pane_sid.get(p["pane_id"])), None) + or next((p for p in panes if p["pane_active"]), None) + or min(panes, key=lambda p: p["pane_index"]) + ) + + +def build_snapshot() -> dict | None: + """Capture the current topology, or None when there's nothing worth keeping (tmux + down, or no Claude windows) — so we never clobber a good last-known-good with an + empty capture around a reboot.""" + if not tmux.available(): + return None + panes = tmux.list_layout() + if not panes: + return None + pane_sid = {s.pane_id: s.session_id for s in live_discovery.discover() if s.pane_id} + + # session_name -> window_index -> [panes], preserving tmux's natural order. + grouped: dict[str, dict[int, list[dict]]] = {} + for p in panes: + grouped.setdefault(p["session_name"], {}).setdefault(p["window_index"], []).append(p) + + out_groups: list[dict] = [] + claude_windows = 0 + for sname, windows in grouped.items(): + wins: list[dict] = [] + for widx in sorted(windows): + rep = _rep_pane(windows[widx], pane_sid) + sid = pane_sid.get(rep["pane_id"]) + is_claude = sid is not None or rep["command"] == "claude" + if is_claude: + claude_windows += 1 + wins.append( + { + "window_name": rep["window_name"], + "cwd": rep["cwd"], + "command": rep["command"], + "kind": "claude" if is_claude else "shell", + "session_id": sid, + } + ) + out_groups.append({"name": sname, "windows": wins}) + + if claude_windows == 0: + return None + return {"ts": _now(), "groups": out_groups} + + +def topology_sig(snapshot: dict) -> str: + """Stable hash of the structure (NOT the timestamp) for cheap change detection.""" + parts = [ + f"{g['name']}|{w['window_name']}|{w['cwd']}|{w.get('session_id') or ''}|{w['kind']}" + for g in snapshot["groups"] + for w in g["windows"] + ] + return hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest() + + +def _live_index() -> tuple[set[str], set[tuple[str, str, str]], set[str]]: + """(live Claude session ids, live (session,window,cwd) triples, live session names) + from the current tmux + Claude discovery — used to skip windows already running.""" + live_sids = {s.session_id for s in live_discovery.discover() if s.session_id} + layout = tmux.list_layout() + live_wins = {(p["session_name"], p["window_name"], p["cwd"]) for p in layout} + session_names = {p["session_name"] for p in layout} + return live_sids, live_wins, session_names + + +def _is_live(gname: str, w: dict, live_sids: set[str], live_wins: set[tuple[str, str, str]]) -> bool: + """Whether a snapshot window is already running. Claude windows key on their session + id (strong); shell windows fall back to (group, name, cwd).""" + sid = w.get("session_id") + if sid: + return sid in live_sids + return (gname, w["window_name"], w["cwd"]) in live_wins + + +def annotate_liveness(snapshot: dict) -> dict: + """Return the snapshot with a per-window `live` flag plus `offer`/`restorable_count`. + `offer` is the clean-reboot signal that drives the UI banner: the snapshot has ≥2 + Claude windows and none of them are currently running.""" + live_sids, live_wins, _ = _live_index() + groups_out: list[dict] = [] + claude_total = claude_live = restorable = 0 + for g in snapshot["groups"]: + wins = [] + for w in g["windows"]: + live = _is_live(g["name"], w, live_sids, live_wins) + if w["kind"] == "claude": + claude_total += 1 + claude_live += 1 if live else 0 + if not live: + restorable += 1 + wins.append({**w, "live": live}) + groups_out.append({"name": g["name"], "windows": wins}) + return { + "ts": snapshot["ts"], + "groups": groups_out, + "offer": claude_total >= 2 and claude_live == 0, + "restorable_count": restorable, + } + + +def _command_for(w: dict) -> str: + """The shell command to launch a restored window. Claude windows resume the exact + session (falling back to the latest conversation in the cwd if that fails); a Claude + window whose id we couldn't capture continues the latest; shells open bare.""" + if w["kind"] != "claude": + return "" # empty → tmux opens the default shell + sid = w.get("session_id") + if sid: + return f"claude --resume {shlex.quote(sid)} || claude --continue" + return "claude --continue" + + +def restore(snapshot: dict, group_names: list[str], only_missing: bool = True) -> dict: + """Rebuild the selected groups from a snapshot. Creates each group's tmux session if + absent and (re)creates its windows in order, skipping any already running so partial + or repeated restores never duplicate. Returns {restored, skipped, groups}.""" + live_sids, live_wins, session_names = _live_index() + wanted = set(group_names) + restored = skipped = 0 + touched: list[str] = [] + + for g in snapshot["groups"]: + if g["name"] not in wanted: + continue + to_make = [ + w + for w in g["windows"] + if not (only_missing and _is_live(g["name"], w, live_sids, live_wins)) + ] + skipped += len(g["windows"]) - len(to_make) + if not to_make: + continue + touched.append(g["name"]) + session_exists = g["name"] in session_names + for w in to_make: + cwd = w["cwd"] if w["cwd"] and os.path.isdir(w["cwd"]) else os.path.expanduser("~") + cmd = _command_for(w) + if not session_exists: + ok, _ = tmux.new_session( + g["name"], cwd=cwd, command=cmd or None, window_name=w["window_name"] + ) + session_exists = True + else: + ok, _ = tmux.new_window(cwd, cmd, session=g["name"], name=w["window_name"]) + restored += 1 if ok else 0 + return {"restored": restored, "skipped": skipped, "groups": touched} diff --git a/backend/muse/autopilot/store.py b/backend/muse/autopilot/store.py index 78ec1c9..6138d41 100644 --- a/backend/muse/autopilot/store.py +++ b/backend/muse/autopilot/store.py @@ -12,7 +12,7 @@ from typing import Optional from .. import db -from ..models import AutopilotConfig, AutopilotLogEntry +from ..models import AutopilotConfig, AutopilotLogEntry, QueuedReply _SCHEMA = """ CREATE TABLE IF NOT EXISTS autopilot_config ( @@ -35,6 +35,24 @@ CREATE TABLE IF NOT EXISTS autopilot_log ( ts TEXT, session_id TEXT, action TEXT, detail TEXT ); +CREATE TABLE IF NOT EXISTS reply_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | cancelled | failed + mode TEXT NOT NULL DEFAULT 'turn', -- turn (own turn) | append (glue to previous) + sent_at TEXT, + error TEXT +); +CREATE INDEX IF NOT EXISTS reply_queue_pending + ON reply_queue(session_id) WHERE status = 'pending'; +CREATE TABLE IF NOT EXISTS tmux_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, -- when captured (ISO) + sig TEXT NOT NULL, -- topology signature (change detection) + snapshot_json TEXT NOT NULL -- serialized {ts, groups:[...]} +); """ # Columns added after the initial release (migrate existing DBs). @@ -49,6 +67,7 @@ # requested against (Phase B discards the draft if the session moved on). "ALTER TABLE autopilot_config ADD COLUMN ai_pending_job_id TEXT", "ALTER TABLE autopilot_config ADD COLUMN ai_requested_updated_at TEXT", + "ALTER TABLE reply_queue ADD COLUMN mode TEXT NOT NULL DEFAULT 'turn'", ] @@ -112,6 +131,33 @@ def _do() -> None: db.retry_locked(_do) + # --- tmux layout snapshots (session restore) --------------------------- + def save_snapshot(self, snapshot_json: str, sig: str) -> None: + """Append a topology snapshot and prune to the last ~20 rows (a small history + ring so a good last-known-good survives even if a newer capture is thin).""" + + def _do() -> None: + with self._lock: + self._conn.execute( + "INSERT INTO tmux_snapshots(ts, sig, snapshot_json) VALUES(?,?,?)", + (_now(), sig, snapshot_json), + ) + self._conn.execute( + "DELETE FROM tmux_snapshots WHERE id NOT IN " + "(SELECT id FROM tmux_snapshots ORDER BY id DESC LIMIT 20)" + ) + self._conn.commit() + + db.retry_locked(_do) + + def latest_snapshot(self) -> Optional[tuple[str, str]]: + """The most recent (sig, snapshot_json), or None if nothing captured yet.""" + with self._lock: + row = self._conn.execute( + "SELECT sig, snapshot_json FROM tmux_snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + return (row["sig"], row["snapshot_json"]) if row else None + # --- active-hours schedule --------------------------------------------- def get_schedule(self) -> tuple[bool, int, int]: enabled = self._get_kv("sched_enabled") == "1" @@ -243,6 +289,131 @@ def get_ai_pending(self, sid: str) -> tuple[Optional[str], Optional[datetime]]: return None, None return row["ai_pending_job_id"], _dt(row["ai_requested_updated_at"]) + # --- reply queue ---------------------------------------------------------- + # User-authored messages waiting for their session's turn to end. The + # controller delivers them (it owns automated tmux writes); these methods are + # just FIFO bookkeeping. Delivered/cancelled rows are kept briefly as history. + + def queue_add(self, sid: str, text: str, mode: str = "turn") -> QueuedReply: + def _do() -> int: + with self._lock: + cur = self._conn.execute( + "INSERT INTO reply_queue(session_id, text, created_at, mode) " + "VALUES(?,?,?,?)", + (sid, text, _now(), mode), + ) + self._conn.commit() + return int(cur.lastrowid) + + qid = db.retry_locked(_do) + return QueuedReply(id=qid, session_id=sid, text=text, mode=mode, + created_at=datetime.now(timezone.utc)) + + def queue_for(self, sid: str, limit: int = 20) -> list[QueuedReply]: + """This session's queue: all pending items (FIFO) + a few recent outcomes.""" + with self._lock: + rows = self._conn.execute( + "SELECT * FROM (SELECT * FROM reply_queue WHERE session_id=? " + "ORDER BY (status='pending') DESC, id DESC LIMIT ?) ORDER BY id ASC", + (sid, limit), + ).fetchall() + return [self._row_to_queued(r) for r in rows] + + def queue_next(self, sid: str) -> Optional[QueuedReply]: + with self._lock: + row = self._conn.execute( + "SELECT * FROM reply_queue WHERE session_id=? AND status='pending' " + "ORDER BY id ASC LIMIT 1", + (sid,), + ).fetchone() + return self._row_to_queued(row) if row else None + + def queue_next_batch(self, sid: str) -> list[QueuedReply]: + """What one delivery should type: the oldest pending item plus any items + immediately after it whose mode is 'append' (they glue on rather than + waiting for their own turn).""" + with self._lock: + rows = self._conn.execute( + "SELECT * FROM reply_queue WHERE session_id=? AND status='pending' " + "ORDER BY id ASC", + (sid,), + ).fetchall() + batch: list[QueuedReply] = [] + for r in rows: + item = self._row_to_queued(r) + if batch and item.mode != "append": + break + batch.append(item) + return batch + + def queue_set_mode(self, sid: str, qid: int, mode: str) -> bool: + """Flip a still-pending item between 'turn' and 'append'.""" + def _do() -> bool: + with self._lock: + cur = self._conn.execute( + "UPDATE reply_queue SET mode=? " + "WHERE id=? AND session_id=? AND status='pending'", + (mode, qid, sid), + ) + self._conn.commit() + return cur.rowcount > 0 + + return db.retry_locked(_do) + + def queue_counts(self) -> dict[str, int]: + """Pending count per session — one cheap query for board/panes badges.""" + with self._lock: + rows = self._conn.execute( + "SELECT session_id, COUNT(*) AS n FROM reply_queue " + "WHERE status='pending' GROUP BY session_id" + ).fetchall() + return {r["session_id"]: r["n"] for r in rows} + + def queue_cancel(self, sid: str, qid: int) -> bool: + """Cancel one still-pending item. False if it's gone or already delivered.""" + def _do() -> bool: + with self._lock: + cur = self._conn.execute( + "UPDATE reply_queue SET status='cancelled' " + "WHERE id=? AND session_id=? AND status='pending'", + (qid, sid), + ) + self._conn.commit() + return cur.rowcount > 0 + + return db.retry_locked(_do) + + def queue_mark(self, qid: int, status: str, error: Optional[str] = None) -> None: + def _do() -> None: + with self._lock: + self._conn.execute( + "UPDATE reply_queue SET status=?, sent_at=?, error=? WHERE id=?", + (status, _now() if status == "sent" else None, error, qid), + ) + # Bound history: keep the most recent 200 non-pending rows. + self._conn.execute( + "DELETE FROM reply_queue WHERE status != 'pending' AND id NOT IN " + "(SELECT id FROM reply_queue WHERE status != 'pending' " + "ORDER BY id DESC LIMIT 200)" + ) + self._conn.commit() + + db.retry_locked(_do) + + @staticmethod + def _row_to_queued(r: sqlite3.Row) -> QueuedReply: + keys = r.keys() + return QueuedReply( + id=r["id"], + session_id=r["session_id"], + text=r["text"], + created_at=_dt(r["created_at"]), + status=r["status"], + mode=(r["mode"] if "mode" in keys else "turn") or "turn", + sent_at=_dt(r["sent_at"]), + error=r["error"], + ) + # --- log ---------------------------------------------------------------- def log(self, sid: str, action: str, detail: str = "") -> None: def _do() -> None: diff --git a/backend/muse/autopilot/tmux.py b/backend/muse/autopilot/tmux.py index 23cf92e..d99093b 100644 --- a/backend/muse/autopilot/tmux.py +++ b/backend/muse/autopilot/tmux.py @@ -5,10 +5,10 @@ import subprocess -def _run(args: list[str], timeout: float = 5.0) -> tuple[int, str, str]: +def _run(args: list[str], timeout: float = 5.0, input: str | None = None) -> tuple[int, str, str]: try: p = subprocess.run( - ["tmux", *args], capture_output=True, text=True, timeout=timeout + ["tmux", *args], capture_output=True, text=True, timeout=timeout, input=input ) return p.returncode, p.stdout, p.stderr except FileNotFoundError: @@ -61,6 +61,8 @@ def list_layout() -> list[dict]: "#{pane_current_path}", "#{pane_title}", "#{session_attached}", + "#{window_activity}", + "#{window_id}", ] ) code, out, _ = _run(["list-panes", "-a", "-F", fmt]) @@ -69,7 +71,7 @@ def list_layout() -> list[dict]: panes: list[dict] = [] for line in out.splitlines(): parts = line.split("\t") - if len(parts) != 11: + if len(parts) != 13: continue try: panes.append( @@ -85,6 +87,11 @@ def list_layout() -> list[dict]: "cwd": parts[8], "title": parts[9], "session_attached": parts[10] != "0", + # Epoch secs of last activity in this window — for most-recent sort. + "last_activity": int(parts[11]) if parts[11].isdigit() else 0, + # Stable window handle (@) — survives index shifts, so it's the + # safe target for move-window. + "window_id": parts[12], } ) except ValueError: @@ -92,17 +99,113 @@ def list_layout() -> list[dict]: return panes -def new_window(cwd: str, command: str, session: str | None = None) -> tuple[bool, str]: +def new_window( + cwd: str, command: str, session: str | None = None, name: str | None = None +) -> tuple[bool, str]: """Open a new tmux window running `command` (a shell string) in `cwd`, appended - to `session` (or tmux's current session if None). Returns (ok, pane_id | error).""" + to `session` (or tmux's current session if None). If `name` is given, label the + window with it. Returns (ok, pane_id | error).""" args = ["new-window"] if session: args += ["-t", f"{session}:"] # append a new window to this session - args += ["-c", cwd, "-P", "-F", "#{pane_id}", command] + args += ["-c", cwd, "-P", "-F", "#{pane_id}"] + if command: # empty command → tmux opens the default shell (restored shell windows) + args.append(command) code, out, err = _run(args) if code != 0: return False, err or "tmux new-window failed" - return True, out.strip() + pane_id = out.strip() + if name: + # Address the window by its pane id (tmux resolves the pane's window). Best-effort: + # rename_window also turns off automatic-rename so the label survives the running + # command changing (claude would otherwise re-title the window to "claude"). + rename_window(pane_id, name) + return True, pane_id + + +def new_session( + name: str, + cwd: str | None = None, + command: str | None = None, + window_name: str | None = None, +) -> tuple[bool, str]: + """Create a detached tmux session `name` to act as a group. With no extras, tmux + births it with one shell window (a scratch pane until real windows move in). When + `cwd`/`command`/`window_name` are given, that first window is created directly running + `command` in `cwd` — so a restored group has no leftover placeholder shell. Returns + (ok, error).""" + args = ["new-session", "-d", "-s", name] + if window_name: + args += ["-n", window_name] + if cwd: + args += ["-c", cwd] + if command: + args.append(command) + code, _, err = _run(args) + if code != 0: + return False, err or "tmux new-session failed" + if window_name: + # Pin the label so the running command can't auto-rename it (see rename_window). + _run(["set-window-option", "-t", f"{name}:", "automatic-rename", "off"]) + return True, "" + + +def rename_session(old: str, new: str) -> tuple[bool, str]: + """Rename a group (tmux session).""" + code, _, err = _run(["rename-session", "-t", old, new]) + return (code == 0), (err if code != 0 else "") + + +def kill_session(name: str) -> tuple[bool, str]: + """Destroy a group (tmux session) and every window/pane in it. Callers must + confirm — this kills whatever is running in the session.""" + code, _, err = _run(["kill-session", "-t", name]) + return (code == 0), (err if code != 0 else "") + + +def kill_window(window_id: str) -> tuple[bool, str]: + """Destroy a single window (addressed by its stable @id) and every pane in it. + Callers must confirm — this kills whatever is running in the window.""" + if not window_id: + return False, "no window" + code, _, err = _run(["kill-window", "-t", window_id]) + return (code == 0), (err if code != 0 else "") + + +def rename_window(window_id: str, new_name: str) -> tuple[bool, str]: + """Rename a window (addressed by its stable @id). First disables automatic-rename + for the window so tmux won't clobber the manual name when the running command + changes; then sets the name.""" + if not window_id: + return False, "no window" + # Best-effort: keep the manual name from being auto-overwritten. Ignore failure + # (older tmux, or the option already off) — the rename below is what matters. + _run(["set-window-option", "-t", window_id, "automatic-rename", "off"]) + code, _, err = _run(["rename-window", "-t", window_id, new_name]) + return (code == 0), (err if code != 0 else "") + + +def move_window(window_id: str, dst_session: str) -> tuple[bool, str]: + """Move a whole window (addressed by its stable @id) into `dst_session`, appending + it after the session's current last window. The moved window's panes keep their + processes and pane ids running — a live Claude is undisturbed. No-op (ok) if the + window is already in the destination session.""" + if not window_id: + return False, "no window" + layout = list_layout() + src = next((p for p in layout if p["window_id"] == window_id), None) + if src is None: + return False, f"window not found: {window_id}" + if src["session_name"] == dst_session: + return True, "" # already there — nothing to do + dst_indices = [p["window_index"] for p in layout if p["session_name"] == dst_session] + if not dst_indices: + return False, f"session not found: {dst_session}" + # Append: one past the destination's current highest window index. Computing the + # index ourselves (rather than the version-dependent -a flag) is deterministic. + target = f"{dst_session}:{max(dst_indices) + 1}" + code, _, err = _run(["move-window", "-s", window_id, "-t", target]) + return (code == 0), (err if code != 0 else "") def send_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]: @@ -120,6 +223,26 @@ def send_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]: return True, "" +def paste_text(pane_id: str, text: str, submit: bool = True) -> tuple[bool, str]: + """Deliver MULTILINE text via a bracketed paste (load-buffer → paste-buffer -p), + then optionally submit. send-keys -l would let the raw LF bytes act as Enter + and submit each line as its own message; a bracketed paste lands in the + composer as one multiline message.""" + if not pane_id: + return False, "no pane" + code, _, err = _run(["load-buffer", "-b", "muse-paste", "-"], input=text) + if code != 0: + return False, err or "load-buffer failed" + code, _, err = _run(["paste-buffer", "-p", "-d", "-b", "muse-paste", "-t", pane_id]) + if code != 0: + return False, err or "paste-buffer failed" + if submit: + code, _, err = _run(["send-keys", "-t", pane_id, "Enter"]) + if code != 0: + return False, err or "enter failed" + return True, "" + + def send_key(pane_id: str, key: str) -> tuple[bool, str]: """Send one named key (e.g. "Escape", "Enter") to a pane. Callers must whitelist — arbitrary keys into a live session are destructive.""" diff --git a/backend/muse/cli.py b/backend/muse/cli.py index d8cd166..edad8f6 100644 --- a/backend/muse/cli.py +++ b/backend/muse/cli.py @@ -170,12 +170,36 @@ def _status() -> int: return 0 +def _hooks(action: str) -> int: + from . import hooksetup + + if action == "install": + print(hooksetup.install()) + return 0 + if action == "uninstall": + print(hooksetup.uninstall()) + return 0 + st = hooksetup.status() + missing = [e for e in st["expected_events"] if e not in st["installed_events"]] + print(f"relay script: {'ok' if st['script_exists'] else 'MISSING'} ({hooksetup.script_path()})") + print(f"endpoint file: {'ok' if st['endpoint_file_exists'] else 'MISSING (written at muse start)'}") + print(f"settings.json: {', '.join(st['installed_events']) or 'no muse hooks'}" + + (f" (missing: {', '.join(missing)})" if missing else "")) + return 0 if (st["script_exists"] and not missing) else 1 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="muse", description="Manage the muse server.") sub = parser.add_subparsers(dest="cmd", required=True) for name in ("start", "stop", "restart", "status"): sub.add_parser(name) + hooks = sub.add_parser( + "hooks", help="install/uninstall the Claude Code hook relay (instant session events)" + ) + hooks.add_argument("action", choices=["install", "uninstall", "status"]) args = parser.parse_args(argv) + if args.cmd == "hooks": + return _hooks(args.action) return {"start": _start, "stop": _stop, "restart": _restart, "status": _status}[args.cmd]() diff --git a/backend/muse/hooksetup.py b/backend/muse/hooksetup.py new file mode 100644 index 0000000..b95ac7c --- /dev/null +++ b/backend/muse/hooksetup.py @@ -0,0 +1,228 @@ +"""Claude Code hook integration: install/uninstall the relay, and the endpoint +file the relay reads. + +Claude Code can run a command on lifecycle events (Stop, Notification, …) and +pipe the event JSON to its stdin. We install ONE tiny relay script that POSTs +that payload to muse's /api/hooks/claude — giving muse instant, authoritative +"turn ended / needs input" signals instead of 2.5–15s polling heuristics. + +Design constraints: +- The relay must NEVER block or break Claude Code: 3s curl timeout, always exit 0. +- No secrets in ~/.claude/settings.json: the script reads ~/.muse/hook_endpoint.json + (0600), which the SERVER rewrites at every startup with its live port + token — + so token rotation or a port change never needs a reinstall. +- settings.json is merged conservatively: only our entries are added/removed; + everything else (rtk hooks, statusline, …) is preserved byte-for-byte in spirit. +""" + +from __future__ import annotations + +import json +import os +import stat +import time +from pathlib import Path +from typing import Optional + +from .config import get_settings + +# Events muse cares about. Stop = turn ended (deliver queued reply / alert); +# Notification = needs permission or sat idle; UserPromptSubmit = user replied +# (clears alert state instantly); SessionEnd = process exited. +HOOK_EVENTS = ["Stop", "Notification", "UserPromptSubmit", "SessionEnd"] + +_SCRIPT = """#!/bin/sh +# muse hook relay (installed by `muse hooks install`). +# POSTs the Claude Code hook payload (stdin) to the local muse server. +# Always exits 0 — a muse outage must never block Claude Code. +f="$HOME/.muse/hook_endpoint.json" +[ -r "$f" ] || exit 0 +url=$(sed -n 's/.*"url": *"\\([^"]*\\)".*/\\1/p' "$f") +tok=$(sed -n 's/.*"token": *"\\([^"]*\\)".*/\\1/p' "$f") +[ -n "$url" ] || exit 0 +if [ -n "$tok" ]; then + curl -s -m 3 -X POST "$url/api/hooks/claude" -H "Content-Type: application/json" \\ + -H "Authorization: Bearer $tok" --data-binary @- >/dev/null 2>&1 +else + curl -s -m 3 -X POST "$url/api/hooks/claude" -H "Content-Type: application/json" \\ + --data-binary @- >/dev/null 2>&1 +fi +exit 0 +""" + + +def state_dir() -> Path: + return get_settings().db_path.parent + + +def script_path() -> Path: + return state_dir() / "hook.sh" + + +def endpoint_path() -> Path: + return state_dir() / "hook_endpoint.json" + + +def claude_settings_path() -> Path: + return get_settings().claude_dir / "settings.json" + + +def write_endpoint_file() -> None: + """(Re)write ~/.muse/hook_endpoint.json with the server's live URL + token. + Called at every server startup so the relay always has current credentials.""" + s = get_settings() + path = endpoint_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "url": f"http://127.0.0.1:{s.port}", + "token": s.resolve_auth_token() or "", + } + path.write_text(json.dumps(payload, indent=1) + "\n", encoding="utf-8") + path.chmod(0o600) + + +def write_script() -> Path: + path = script_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_SCRIPT, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + return path + + +# --- settings.json merge (pure, unit-testable) -------------------------------- + + +def _is_ours(hook: dict, command: str) -> bool: + return hook.get("type") == "command" and hook.get("command") == command + + +def add_muse_hooks(settings: dict, command: str) -> dict: + """Return a copy of `settings` with the muse relay registered for each of + HOOK_EVENTS. Idempotent; never touches other hooks.""" + out = json.loads(json.dumps(settings)) # deep copy, JSON-safe by construction + hooks = out.setdefault("hooks", {}) + for event in HOOK_EVENTS: + matchers = hooks.setdefault(event, []) + present = any( + _is_ours(h, command) + for m in matchers + if isinstance(m, dict) + for h in m.get("hooks", []) + if isinstance(h, dict) + ) + if not present: + matchers.append({"hooks": [{"type": "command", "command": command}]}) + return out + + +def remove_muse_hooks(settings: dict, command: Optional[str] = None) -> dict: + """Return a copy of `settings` with every muse relay entry stripped (matched + by exact command, or by the hook.sh basename when command is None). Empty + matcher groups / event lists left behind are pruned.""" + marker = command or str(script_path()) + out = json.loads(json.dumps(settings)) + hooks = out.get("hooks") + if not isinstance(hooks, dict): + return out + for event in list(hooks): + matchers = hooks[event] + if not isinstance(matchers, list): + continue + for m in matchers: + if isinstance(m, dict) and isinstance(m.get("hooks"), list): + m["hooks"] = [ + h for h in m["hooks"] + if not (isinstance(h, dict) and h.get("type") == "command" + and marker in str(h.get("command", ""))) + ] + hooks[event] = [ + m for m in matchers + if not (isinstance(m, dict) and m.get("hooks") == [] and set(m) <= {"hooks", "matcher"}) + ] + if hooks[event] == []: + del hooks[event] + if hooks == {}: + del out["hooks"] + return out + + +# --- install / uninstall / status (used by the CLI) --------------------------- + + +def _read_settings(path: Path) -> dict: + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_settings(path: Path, settings: dict) -> None: + # Keep a timestamped backup under ~/.muse (never clobber the user's config + # without a way back). + if path.is_file(): + backups = state_dir() / "backups" + backups.mkdir(parents=True, exist_ok=True) + (backups / f"settings.json.{int(time.time())}").write_text( + path.read_text(encoding="utf-8"), encoding="utf-8" + ) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.muse-tmp") + tmp.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +def install() -> str: + script = write_script() + path = claude_settings_path() + settings = _read_settings(path) + merged = add_muse_hooks(settings, str(script)) + if merged != settings: + _write_settings(path, merged) + if not endpoint_path().is_file(): + # The server rewrites this at startup; seed it now so hooks work + # immediately if muse is already running on the default port. + try: + write_endpoint_file() + except OSError: + pass + return ( + f"Installed muse hooks ({', '.join(HOOK_EVENTS)}) → {script}\n" + f"Relay target: {endpoint_path()} (rewritten at every muse start).\n" + f"New Claude Code sessions pick this up automatically; running ones on next launch." + ) + + +def uninstall() -> str: + path = claude_settings_path() + settings = _read_settings(path) + stripped = remove_muse_hooks(settings) + if stripped != settings: + _write_settings(path, stripped) + try: + script_path().unlink() + except OSError: + pass + return "Removed muse hooks from Claude Code settings." + + +def status() -> dict: + """Local install state (no server round-trip).""" + script = script_path() + settings = _read_settings(claude_settings_path()) if claude_settings_path().is_file() else {} + installed_events = [ + event + for event, matchers in (settings.get("hooks") or {}).items() + if isinstance(matchers, list) + and any( + str(script) in str(h.get("command", "")) + for m in matchers + if isinstance(m, dict) + for h in m.get("hooks", []) + if isinstance(h, dict) + ) + ] + return { + "script_exists": script.is_file(), + "endpoint_file_exists": endpoint_path().is_file(), + "installed_events": sorted(installed_events), + "expected_events": HOOK_EVENTS, + } diff --git a/backend/muse/main.py b/backend/muse/main.py index 60d18a0..8ee3779 100644 --- a/backend/muse/main.py +++ b/backend/muse/main.py @@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from . import db, lifecycle +from . import db, hooksetup, lifecycle from .auth import AuthMiddleware from .compression import GzipBufferedMiddleware from .config import get_settings @@ -29,12 +29,14 @@ auth, autopilot, board, + hooks, insights, interact, investigations, launch, notify, options, + queue, sessions, stream, tmux, @@ -66,6 +68,19 @@ async def lifespan(app: FastAPI): app.state.autopilot = AutopilotController() # Parsed usage-limit resets anchor stats' 5h window (observed > estimated). app.state.autopilot.on_reset = app.state.service.usage_history.record_reset + + # Limit-hit sightings calibrate the runway's observed ceiling: the window's + # spend at the moment of the hit is the plan's effective cap (subscription + # plans publish no $ numbers, so the wall is only learnable by hitting it). + def _on_limit(kind: str) -> None: + from . import runway as runway_mod + + rw = runway_mod.compute_runway(app.state.service.usage_history) + cost = rw.five_hour.cost_usd if kind == "5h" else rw.week.cost_usd + if cost > 0: + app.state.service.usage_history.record_limit_hit(kind, cost) + + app.state.autopilot.on_limit = _on_limit # AI idle mode: the controller requests drafts and reads results through # these callables (it never imports the service or touches the AI worker). app.state.autopilot.enqueue_draft = app.state.service.enqueue_draft_reply @@ -74,6 +89,15 @@ async def lifespan(app: FastAPI): app.state.autopilot.start() app.state.alerts = AlertsWatcher(app.state.service) app.state.alerts.start() + # Claude Code hook ingestion: refresh the relay's endpoint file (port+token + # can change between runs) and prime the recent-events ring. + try: + hooksetup.write_endpoint_file() + except OSError: + pass + from collections import deque as _deque + + app.state.hook_events = _deque(maxlen=200) # AI worker: single daemon thread executing headless `claude -p` jobs. get_settings().ai_workdir.mkdir(parents=True, exist_ok=True) app.state.service.ai_worker.start() @@ -145,6 +169,8 @@ def create_app() -> FastAPI: app.include_router(insights.router) app.include_router(interact.router) app.include_router(options.router) + app.include_router(queue.router) + app.include_router(hooks.router) app.include_router(tmux.router) app.include_router(auth.router) diff --git a/backend/muse/models.py b/backend/muse/models.py index f6c16e7..05b06aa 100644 --- a/backend/muse/models.py +++ b/backend/muse/models.py @@ -642,6 +642,7 @@ class TmuxPane(BaseModel): pane_id: str session_name: str window_index: int + window_id: str = "" # stable window handle (@) — the target for move-window window_name: str window_active: bool pane_index: int @@ -650,15 +651,21 @@ class TmuxPane(BaseModel): cwd: str title: str = "" session_attached: bool = True # False => detached session (scratch/background) + last_activity: int = 0 # epoch secs of last window activity (most-recent sort) muse_session_id: Optional[str] = None # set if this pane runs a tracked session context_pct: Optional[float] = None # context-window occupancy (tracked sessions) + queued: int = 0 # replies queued for delivery when this session's turn ends # Attention status for grouping the mobile task list. # responded = a live session whose turn ended (a response is ready for you). status: Literal["needs_you", "responded", "working", "idle"] = "idle" attention: str = "" # short reason, e.g. "permission prompt", "working" # Claude Code's permission mode (Shift+Tab cycles it); None for non-Claude panes. mode: Optional[Literal["default", "acceptEdits", "plan", "bypass"]] = None - preview: str = "" # recent captured lines (plain text) + # Full screen text is heavy (hundreds of KB across a fleet) — it ships only + # when the client asks (?previews=1); the one-line tail always ships for + # task-list subtitles. The deck fetches live screens per-pane instead. + preview: str = "" # visible screen (ANSI), only when previews=1 + preview_tail: str = "" # last visible line (ANSI stripped, short) options: list[PendingOption] = Field(default_factory=list) # menu detected in buffer @@ -668,6 +675,78 @@ class TmuxLayout(BaseModel): reason: Optional[str] = None +class SlashCommand(BaseModel): + """One entry in the composer's "/" autocomplete: a Claude Code slash command + available to the pane's session (built-in, user-global, or project-local).""" + + name: str # invoked as "/{name}" — subdir commands are namespaced with ":" + description: str = "" + source: Literal["builtin", "user", "project"] = "builtin" + + +class QueuedReply(BaseModel): + """A user-authored message waiting to be typed into a session's pane the next + time that session is genuinely idle (turn ended, no menu pending).""" + + id: int + session_id: str + text: str + created_at: Optional[datetime] = None + status: Literal["pending", "sent", "cancelled", "failed"] = "pending" + # turn = deliver alone and let it run a full turn; append = glue onto the + # previous queued item so both go in one message. + mode: Literal["turn", "append"] = "turn" + sent_at: Optional[datetime] = None + error: Optional[str] = None + + +class QueueView(BaseModel): + """A session's queue plus why it isn't delivering right now (so the UI can + explain a held reply instead of leaving it silently pending).""" + + items: list[QueuedReply] = [] + hold_reason: Optional[str] = None + + +class RunwaySession(BaseModel): + session_id: str + title: str = "" + cost_usd: float = 0.0 + + +class RunwayWindow(BaseModel): + """Spend vs budget for one rate-limit window (5h or weekly).""" + + label: str + window_seconds: int + anchor: Optional[datetime] = None # window start + anchor_source: str = "estimated" # "reset" when anchored to an observed reset + elapsed_seconds: int = 0 + remaining_seconds: int = 0 + cost_usd: float = 0.0 + budget_usd: Optional[float] = None + # Where the budget came from: "configured" (MUSE_LIMIT_*_USD), "observed" + # (spend at the last real limit hit), or "none" (subscription plan with no + # calibration yet — show spend without a ceiling, never a made-up estimate). + budget_source: str = "none" + pct_used: Optional[float] = None # cost/budget, None without a budget + pct_elapsed: float = 0.0 # time progress through the window + + +class RunwayResponse(BaseModel): + """How much headroom is left before hitting the plan's usage limits — the + fleet-driving question ('can I keep all these sessions running?').""" + + generated_at: datetime + plan_label: Optional[str] = None + five_hour: RunwayWindow + week: RunwayWindow + burn_usd_per_hour: float = 0.0 # trailing burn rate (last 30 min, annualized to 1h) + projected_exhaust_at: Optional[datetime] = None # when the 5h budget runs out at this burn + exhaust_before_reset: bool = False # True => you'll hit the limit before the window resets + top_sessions: list[RunwaySession] = Field(default_factory=list) # burners this 5h window + + class Bookmark(BaseModel): message_uuid: str note: str = "" diff --git a/backend/muse/profiles.py b/backend/muse/profiles.py new file mode 100644 index 0000000..36dfe0a --- /dev/null +++ b/backend/muse/profiles.py @@ -0,0 +1,191 @@ +"""Launch profiles: named templates for opening a new tmux window. + +A profile pins a working directory, runs an arbitrary shell `command` (typically a +`setup && claude` chain), and may declare `params` that muse collects in a small dialog +and substitutes into the command before launch. Profiles are hand-authored in a TOML file +(``~/.muse/profiles.toml`` by default) — muse only READS them; there is no in-app editor. + +The plain "new claude window" is just the built-in ``Claude`` default profile, so the +feature degrades gracefully when no config file exists. +""" + +from __future__ import annotations + +import fnmatch +import os +import shlex +import subprocess +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationError + +from .config import get_settings + +try: # Python 3.11+ + import tomllib +except ModuleNotFoundError: # 3.10 — use the backport + import tomli as tomllib + + +class ProfileError(Exception): + """A profiles.toml that can't be read/parsed/validated. Surfaced to the UI as 400.""" + + +class ProfileParam(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str + prompt: str + default: str = "" + + +class Profile(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + cwd: str = "~" + command: str = "claude" + params: list[ProfileParam] = [] + window_name: str = "" # tmux window label template; {key} params substituted + # Session-removal cleanup (see match_cleanup): a window whose cwd matches match_cwd + # belongs to this profile; `cleanup` is the teardown shell command run on removal. + match_cwd: str = "" # glob (e.g. "~/workspace/igloo-dev/projects/*") + cleanup: str = "" # command run from the profile's cwd; {basename}/{window}/{cwd} subbed + builtin: bool = False + + +# Always available so "New window" works with no config file. A file profile named +# "Claude" (case-insensitive) overrides this. +DEFAULT_PROFILE = Profile(name="Claude", cwd="~", command="claude", builtin=True) + + +def profiles_path() -> Path: + """Where profiles are read from. ``MUSE_PROFILES_PATH`` overrides; otherwise it sits + beside muse's DB (``~/.muse/profiles.toml``). Reads the env directly rather than the + lru-cached Settings so tests can point it at a tmp file per-test.""" + env = os.environ.get("MUSE_PROFILES_PATH") + if env: + return Path(env).expanduser() + return get_settings().db_path.parent / "profiles.toml" + + +def load_profiles() -> list[Profile]: + """Built-in default first, then user profiles from the TOML file (in file order). + + Raises ``ProfileError`` on a decode error, a non-array ``profile`` key, a duplicate + name, or a schema failure — the router turns that into a 400 so a broken config shows + an actionable message instead of silently doing nothing.""" + path = profiles_path() + if not path.exists(): + return [DEFAULT_PROFILE] + + try: + data = tomllib.loads(path.read_text()) + except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError) as e: + raise ProfileError(f"could not read {path}: {e}") from e + + raw = data.get("profile", []) + if not isinstance(raw, list): + raise ProfileError("profiles.toml: expected an array of [[profile]] tables") + + file_profiles: list[Profile] = [] + seen: set[str] = set() + for i, item in enumerate(raw): + if not isinstance(item, dict): + raise ProfileError(f"profile #{i + 1}: expected a [[profile]] table") + try: + p = Profile.model_validate({**item, "builtin": False}) + except ValidationError as e: + raise ProfileError(f"profile #{i + 1}: {e}") from e + low = p.name.lower() + if low in seen: + raise ProfileError(f"duplicate profile name: {p.name!r}") + seen.add(low) + file_profiles.append(p) + + # File wins: only prepend the built-in default when the file doesn't define "Claude". + out: list[Profile] = [] + if DEFAULT_PROFILE.name.lower() not in seen: + out.append(DEFAULT_PROFILE) + out.extend(file_profiles) + return out + + +def find_profile(name: str) -> Profile | None: + for p in load_profiles(): + if p.name == name: + return p + return None + + +def render(profile: Profile, values: dict[str, str]) -> tuple[str, str]: + """Return ``(cwd, command)`` with declared ``{key}`` params substituted. + + cwd params are substituted RAW — cwd is passed to tmux as a literal ``-c`` argument + that is not shell-interpreted. command params are ``shlex.quote``d — command is a + shell string tmux runs, so a value with a space/metachar becomes one safe token. + Missing values fall back to the param's ``default``.""" + cwd, command = profile.cwd, profile.command + for param in profile.params: + val = values.get(param.key, param.default) + cwd = cwd.replace("{" + param.key + "}", val) + command = command.replace("{" + param.key + "}", shlex.quote(val)) + return os.path.expanduser(cwd), command + + +def window_label(profile: Profile, values: dict[str, str]) -> str: + """The tmux window name for a launched profile — so a new window is easy to spot + instead of the generic "claude". Uses the profile's ``window_name`` template if set; + otherwise the first param's value (e.g. the issue name), else the profile name. + ``{key}`` params are substituted raw (it's a label, not shell); the result is + whitespace-collapsed and truncated to a sane tmux width.""" + tmpl = profile.window_name + if not tmpl: + tmpl = "{" + profile.params[0].key + "}" if profile.params else profile.name + for param in profile.params: + tmpl = tmpl.replace("{" + param.key + "}", values.get(param.key, param.default)) + return " ".join(tmpl.split())[:40] + + +def match_cleanup(cwd: str, window_name: str) -> dict | None: + """Match a running window (by its cwd) to a profile's cleanup, for session removal. + + Returns ``{profile, command, run_cwd}`` for the first profile whose ``match_cwd`` glob + matches ``cwd`` and that defines a ``cleanup`` command, or None. The cleanup command's + window-derived vars — ``{basename}`` (of the cwd), ``{window}`` (name), ``{cwd}`` — are + shell-quoted (cleanup runs via a shell); the launch params aren't persisted, so cleanup + references these instead. It runs from the profile's cwd (where its scripts live).""" + if not cwd: + return None + for p in load_profiles(): + if not (p.cleanup and p.match_cwd): + continue + if not fnmatch.fnmatch(os.path.expanduser(cwd), os.path.expanduser(p.match_cwd)): + continue + subs = { + "cwd": cwd, + "basename": os.path.basename(cwd.rstrip("/")), + "window": window_name or "", + } + command = p.cleanup + for k, v in subs.items(): + command = command.replace("{" + k + "}", shlex.quote(v)) + return {"profile": p.name, "command": command, "run_cwd": os.path.expanduser(p.cwd)} + return None + + +def run_cleanup(command: str, run_cwd: str) -> tuple[bool, str]: + """Run a profile cleanup command (trusted user config, like the launch command) headless + in ``run_cwd``. Returns (ok, combined stdout+stderr, trimmed). Bounded by a timeout so a + hung teardown can't wedge the request.""" + try: + p = subprocess.run( + command, + shell=True, + cwd=run_cwd if os.path.isdir(run_cwd) else None, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + return False, "cleanup timed out after 120s" + out = (p.stdout + p.stderr).strip() + return p.returncode == 0, out[-2000:] diff --git a/backend/muse/routers/hooks.py b/backend/muse/routers/hooks.py new file mode 100644 index 0000000..57bd34a --- /dev/null +++ b/backend/muse/routers/hooks.py @@ -0,0 +1,108 @@ +"""Ingest Claude Code hook events (the relay installed by `muse hooks install`). + +Claude Code pipes each lifecycle event's JSON to ~/.muse/hook.sh, which POSTs it +here. This turns muse's picture of the fleet from "polled every few seconds" +into "told the instant something happens": + +- Stop → deliver the next queued reply for that session immediately; + if nothing was queued, push a (settled) "ready for you" alert. +- Notification → push "needs your permission / input" immediately. +- UserPromptSubmit→ the user replied — cancel any in-flight turn-ended alert. +- SessionEnd → push "session ended" (when that alert rule is on). + +The handler itself must return FAST (Claude Code waits on the relay, which waits +on curl): all real work runs as a background task on the event loop. The polling +watcher stays as the fallback for sessions without hooks; suppression windows in +AlertsWatcher keep the two sources from double-firing. +""" + +from __future__ import annotations + +import asyncio +import re +from collections import deque +from datetime import datetime, timezone + +from fastapi import APIRouter, Request + +from .. import hooksetup + +router = APIRouter(prefix="/api", tags=["hooks"]) + +_SID_RE = re.compile(r"^[0-9a-fA-F-]{8,64}$") +# Small pause after Stop before typing a queued reply: the TUI needs a beat to +# repaint its prompt after the turn ends. +_QUEUE_DELIVER_DELAY = 1.0 + + +def _ring(request: Request) -> deque: + ring = getattr(request.app.state, "hook_events", None) + if ring is None: + ring = request.app.state.hook_events = deque(maxlen=200) + return ring + + +async def _dispatch(state, event: str, sid: str, payload: dict) -> None: + alerts = state.alerts + if event == "Stop": + await asyncio.sleep(_QUEUE_DELIVER_DELAY) + delivered = await asyncio.to_thread(state.autopilot.deliver_queued, sid) + if delivered: + # The queued reply is the user's answer — no "ready for you" push, + # and any in-flight settle alert is superseded. + alerts.hook_user_replied(sid) + else: + await alerts.hook_turn_ended(sid) + elif event == "Notification": + await alerts.hook_needs_you(sid, str(payload.get("message") or "")) + elif event == "UserPromptSubmit": + alerts.hook_user_replied(sid) + elif event == "SessionEnd": + await alerts.hook_session_end(sid) + # Other events (SubagentStop, PreCompact, …) are recorded but need no action. + + +@router.post("/hooks/claude") +async def claude_hook(request: Request) -> dict: + """Accept one Claude Code hook payload. Never errors on bad input — the + relay must always succeed from Claude Code's point of view.""" + try: + payload = await request.json() + except Exception: + return {"ok": False, "detail": "unparseable payload"} + if not isinstance(payload, dict): + return {"ok": False, "detail": "not an object"} + event = str(payload.get("hook_event_name") or "") + sid = str(payload.get("session_id") or "") + _ring(request).appendleft( + { + "ts": datetime.now(timezone.utc).isoformat(), + "event": event, + "session_id": sid, + "message": str(payload.get("message") or "")[:200], + } + ) + if event and _SID_RE.match(sid): + # Fire-and-forget: the relay's curl has a 3s timeout and Claude Code is + # waiting on it — the settle delays alone exceed that. + asyncio.get_running_loop().create_task( + _dispatch(request.app.state, event, sid, payload) + ) + return {"ok": True} + + +@router.get("/hooks/status") +def hooks_status(request: Request) -> dict: + """Install state + live ingest counters (the AlertsPage 'instant telemetry' + card and `muse hooks status` both read this).""" + ring = _ring(request) + by_event: dict[str, int] = {} + for e in ring: + by_event[e["event"]] = by_event.get(e["event"], 0) + 1 + return { + **hooksetup.status(), + "events_seen": len(ring), + "by_event": by_event, + "last_event_at": ring[0]["ts"] if ring else None, + "recent": list(ring)[:20], + } diff --git a/backend/muse/routers/insights.py b/backend/muse/routers/insights.py index d743e52..756c17a 100644 --- a/backend/muse/routers/insights.py +++ b/backend/muse/routers/insights.py @@ -5,7 +5,8 @@ from fastapi import APIRouter, HTTPException, Query, Request -from ..models import OutcomesResponse, TimelineResponse +from .. import runway as runway_mod +from ..models import OutcomesResponse, RunwayResponse, TimelineResponse router = APIRouter(prefix="/api", tags=["insights"]) @@ -14,6 +15,13 @@ def _service(request: Request): return request.app.state.service +@router.get("/runway", response_model=RunwayResponse) +def runway(request: Request) -> RunwayResponse: + """Budget headroom in the current 5h/weekly windows — cheap enough for the + cockpit to poll (TTL-cached over the mtime-cached usage scan).""" + return runway_mod.get_runway(_service(request).usage_history) + + @router.get("/insights", response_model=OutcomesResponse) def insights(request: Request, days: int = 30) -> OutcomesResponse: if days not in (0, 7, 30, 90): diff --git a/backend/muse/routers/queue.py b/backend/muse/routers/queue.py new file mode 100644 index 0000000..e10fd1d --- /dev/null +++ b/backend/muse/routers/queue.py @@ -0,0 +1,86 @@ +"""Queued replies: leave a message for a BUSY session; muse types it into the +session's pane when the turn actually ends (idle, no menu, no rate-limit banner). + +This is the "send when ready" counterpart to interact.py's immediate send. The +delivery itself lives in the autopilot controller (the one disciplined automated +tmux-write site) — these endpoints are just the user-facing queue bookkeeping. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from ..models import QueuedReply, QueueView + +router = APIRouter(prefix="/api", tags=["queue"]) + +_MAX_TEXT = 4000 + + +class QueueRequest(BaseModel): + text: str + mode: str = "turn" # turn = own turn; append = glue onto the previous item + + +class QueueModeRequest(BaseModel): + mode: str # turn | append + + +@router.get("/sessions/{session_id}/queue") +def get_queue(session_id: str, request: Request) -> QueueView: + ap = request.app.state.autopilot + return QueueView( + items=ap.store.queue_for(session_id), + hold_reason=ap.queue_hold_reason(session_id), + ) + + +@router.post("/sessions/{session_id}/queue") +def add_to_queue(session_id: str, body: QueueRequest, request: Request) -> QueuedReply: + text = body.text.strip() + if not text: + raise HTTPException(status_code=400, detail="text is empty") + if len(text) > _MAX_TEXT: + raise HTTPException(status_code=400, detail=f"text too long (max {_MAX_TEXT})") + if body.mode not in ("turn", "append"): + raise HTTPException(status_code=400, detail=f"unknown mode: {body.mode!r}") + ap = request.app.state.autopilot + item = ap.store.queue_add(session_id, text, mode=body.mode) + ap.store.log(session_id, "queue_add", f"#{item.id} {text[:80]}") + return item + + +@router.post("/sessions/{session_id}/queue/{qid}/mode") +def set_queued_mode( + session_id: str, qid: int, body: QueueModeRequest, request: Request +) -> dict: + """Flip a pending item between 'turn' (its own turn) and 'append' (glue onto + the previous item so both go in one message).""" + if body.mode not in ("turn", "append"): + raise HTTPException(status_code=400, detail=f"unknown mode: {body.mode!r}") + ap = request.app.state.autopilot + if not ap.store.queue_set_mode(session_id, qid, body.mode): + raise HTTPException(status_code=404, detail="not pending (already sent or cancelled)") + return {"ok": True, "mode": body.mode} + + +@router.post("/sessions/{session_id}/queue/send-now") +def send_queued_now(session_id: str, request: Request) -> dict: + """User override: deliver the next queued batch immediately, skipping the + wait-for-idle gate. Still refuses to type over an open menu / limit banner.""" + ap = request.app.state.autopilot + sent, reason = ap.deliver_now(session_id) + if not sent: + raise HTTPException(status_code=409, detail=reason or "nothing to send") + ap.store.log(session_id, "queue_send_now", f"{sent}") + return {"ok": True, "sent": sent} + + +@router.delete("/sessions/{session_id}/queue/{qid}") +def cancel_queued(session_id: str, qid: int, request: Request) -> dict: + ap = request.app.state.autopilot + if not ap.store.queue_cancel(session_id, qid): + raise HTTPException(status_code=404, detail="not pending (already sent or cancelled)") + ap.store.log(session_id, "queue_cancel", f"#{qid}") + return {"ok": True} diff --git a/backend/muse/routers/tmux.py b/backend/muse/routers/tmux.py index eed8dca..06a5489 100644 --- a/backend/muse/routers/tmux.py +++ b/backend/muse/routers/tmux.py @@ -9,30 +9,91 @@ from __future__ import annotations +import json import os import re import time +from concurrent.futures import ThreadPoolExecutor from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from .. import options as opt +from .. import profiles as profiles_mod +from .. import slash_commands from .. import usage_cache from ..autopilot import sessions as live_discovery +from ..autopilot import snapshot as layout_snapshot from ..autopilot import tmux -from ..models import PendingOption, TmuxLayout, TmuxPane +from ..models import PendingOption, SlashCommand, TmuxLayout, TmuxPane router = APIRouter(prefix="/api/tmux", tags=["tmux"]) _PANE_RE = re.compile(r"^%\d+$") -# Named keys safe to send into an arbitrary pane for menu navigation. -_KEYS = {"escape": "Escape", "enter": "Enter", "up": "Up", "down": "Down"} +_WINDOW_RE = re.compile(r"^@\d+$") +# tmux session names double as command targets, where "." and ":" are separators — +# restrict group names to a safe alphabet so they can't break `-t session:index`. +_SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$") +# Named keys safe to send into an arbitrary pane, mapped to tmux send-keys names. +_KEYS = { + "escape": "Escape", + "enter": "Enter", + "tab": "Tab", + "backspace": "BSpace", + "delete": "DC", + "space": "Space", + "up": "Up", + "down": "Down", + "left": "Left", + "right": "Right", + "home": "Home", + "end": "End", + "pageup": "PageUp", + "pagedown": "PageDown", +} +# Modifier prefixes on a key string: "c-c" → Ctrl-C, "m-f" → Alt-F, "c-left". +_MOD_RE = re.compile(r"^((?:[cms]-)+)(.+)$") +_FN_RE = re.compile(r"^f([1-9]|1[0-2])$") + + +def _resolve_key(raw: str) -> Optional[tuple[str, bool]]: + """Resolve a UI key string to (tmux key, is_literal). is_literal means send it + as raw text (send-keys -l), for printable characters like / | -. Returns None + if the key isn't in the allowlist.""" + k = raw.strip() + kl = k.lower() + if _FN_RE.match(kl): + return kl.upper(), False # F1..F12 + m = _MOD_RE.match(kl) + if m: + prefix = m.group(1).upper() # C- / M- / S- (combinable) + base = m.group(2) + if base in _KEYS: + return prefix + _KEYS[base], False + if len(base) == 1 and base.isprintable(): + return prefix + base, False + return None + if kl in _KEYS: + return _KEYS[kl], False + if len(k) == 1 and k.isprintable(): + return k, True # literal char (/, |, -, …) + return None _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") # A full-width run of the same rule/border char (Claude's input box, separators) # wraps into many junk lines on a phone — collapse long runs to a short rule. _RULE_RE = re.compile(r"([_\-─━═—–▁])\1{11,}") +def _tail_line(cleaned: str) -> str: + """Last visible line of a cleaned screen, ANSI-stripped and short — the + task-list subtitle when there's no attention text.""" + for ln in reversed(cleaned.splitlines()): + vis = _ANSI_RE.sub("", ln).strip() + if vis: + return vis[:100] + return "" + + def _clean_preview(text: str) -> str: """Tidy a captured pane for display: right-trim each line, drop blank leading/trailing lines, and collapse runs of blank lines to one. ANSI-aware: @@ -66,12 +127,51 @@ class NewSession(BaseModel): cwd: str | None = None # where to start Claude (default: home) +class LaunchProfile(BaseModel): + values: dict[str, str] = {} # collected values for the profile's declared params + session: str | None = None # target group (tmux session); default = _default_session() + + +class GroupBody(BaseModel): + name: str # tmux session name — create target, or rename destination + + +class MoveWindow(BaseModel): + session: str # destination group (tmux session) for this window + + +class CloseWindow(BaseModel): + cleanup: bool = False # also run the matching profile's teardown command + + def _require_pane(pane_id: str) -> str: if not _PANE_RE.match(pane_id): raise HTTPException(status_code=400, detail=f"invalid pane id: {pane_id!r}") return pane_id +def _require_window(window_id: str) -> str: + if not _WINDOW_RE.match(window_id): + raise HTTPException(status_code=400, detail=f"invalid window id: {window_id!r}") + return window_id + + +def _require_session_name(name: str) -> str: + if not _SESSION_RE.match(name): + raise HTTPException(status_code=400, detail=f"invalid group name: {name!r}") + return name + + +def _window_repr(window_id: str) -> tuple[str, str]: + """The (cwd, window_name) of a window's representative pane (active, else first), + looked up from the live layout. 404 if the window isn't found.""" + panes = [p for p in tmux.list_layout() if p["window_id"] == window_id] + if not panes: + raise HTTPException(status_code=404, detail=f"window not found: {window_id}") + rep = next((p for p in panes if p["pane_active"]), panes[0]) + return rep["cwd"], rep["window_name"] + + def _default_session() -> str | None: """Pick the tmux session to add a new window to: the attached one with the most windows (your main workspace), falling back to whatever has the most windows.""" @@ -89,7 +189,11 @@ def _default_session() -> str | None: @router.get("/layout", response_model=TmuxLayout) -def layout(request: Request) -> TmuxLayout: +def layout(request: Request, previews: int = 1) -> TmuxLayout: + """The whole tmux topology. `previews=0` omits the (heavy) per-pane screen + text — the phone task list polls this shape; the deck fetches live screens + per-pane via /panes/{id}/screen instead. Menu/mode/attention detection always + runs server-side (it needs the capture regardless).""" if not tmux.available(): return TmuxLayout(available=False, reason="tmux is not installed or not running") raw = tmux.list_layout() @@ -100,13 +204,19 @@ def layout(request: Request) -> TmuxLayout: live_by_pane = {ls.pane_id: ls for ls in live_discovery.discover() if ls.pane_id} # Cheap (mtime-cached) per-session context-window occupancy, same source as the board. _, ctx_pcts = usage_cache.board_rollup() + # Pending queued replies per session (one query) — badge "⏳n" on the task list. + queued = request.app.state.autopilot.store.queue_counts() + + # One `tmux capture-pane` subprocess per pane: run them concurrently — a + # ~24-pane fleet takes ~180ms serially, which is most of the poll's latency. + with ThreadPoolExecutor(max_workers=min(8, len(raw))) as pool: + screens = list(pool.map(lambda p: tmux.capture_visible(p["pane_id"], ansi=True), raw)) panes: list[TmuxPane] = [] - for p in raw: + for p, screen in zip(raw, screens): # The live visible screen is the only accurate, current view (scrollback is # a mix of stale frames from previous commands). One capture serves both the # colored preview and menu detection (the parser strips ANSI itself). - screen = tmux.capture_visible(p["pane_id"], ansi=True) preview = _clean_preview(screen) menu = opt.parse_permission_menu(screen) options = ( @@ -129,6 +239,7 @@ def layout(request: Request) -> TmuxLayout: pane_id=p["pane_id"], session_name=p["session_name"], window_index=p["window_index"], + window_id=p.get("window_id", ""), window_name=p["window_name"], window_active=p["window_active"], pane_index=p["pane_index"], @@ -137,18 +248,52 @@ def layout(request: Request) -> TmuxLayout: cwd=p["cwd"], title=p["title"], session_attached=p["session_attached"], + last_activity=p["last_activity"], muse_session_id=ls.session_id if ls else None, context_pct=ctx_pcts.get(ls.session_id) if ls else None, + queued=queued.get(ls.session_id, 0) if ls else 0, status=status, attention=attention, mode=mode, - preview=preview, + preview=preview if previews else "", + preview_tail=_tail_line(preview), options=options, ) ) return TmuxLayout(available=True, panes=panes) +@router.get("/panes/{pane_id}/screen") +def screen(pane_id: str) -> dict: + """One pane's live visible screen (cleaned, ANSI kept for colors) + the menu + and permission mode parsed from that same capture. The deck polls this for + the pane you're actually looking at — fresher than the layout poll and a + fraction of its payload.""" + _require_pane(pane_id) + raw_screen = tmux.capture_visible(pane_id, ansi=True) + preview = _clean_preview(raw_screen) + menu = opt.parse_permission_menu(raw_screen) + return { + "ok": bool(raw_screen), + "text": preview, + "mode": opt.parse_mode(raw_screen), + "options": [ + {"id": o.id, "label": o.label, "description": o.description, "kind": o.kind} + for o in (menu.options if menu else []) + ], + } + + +@router.get("/panes/{pane_id}/commands", response_model=list[SlashCommand]) +def commands(pane_id: str) -> list[SlashCommand]: + """Slash commands available to this pane's Claude session — for the composer's + "/" autocomplete. Resolves the pane's working dir from tmux, then reads the + built-in + user + project command sets rooted there (read-only).""" + _require_pane(pane_id) + cwd = next((p["cwd"] for p in tmux.list_layout() if p["pane_id"] == pane_id), None) + return [SlashCommand(**c) for c in slash_commands.list_commands(cwd)] + + def _attention(ls, has_menu: bool) -> tuple[str, str]: """Classify a pane for the mobile task list: what does it need from the user? @@ -189,15 +334,22 @@ def send(pane_id: str, body: PaneSend, request: Request) -> dict: @router.post("/panes/{pane_id}/key") def key(pane_id: str, body: PaneKey, request: Request) -> dict: _require_pane(pane_id) - k = body.key.strip().lower() - if k == "accept": + k = body.key.strip() + kl = k.lower() + if kl == "accept": ok, err = tmux.accept_suggestion(pane_id) - elif k.isdigit() and len(k) == 1 and k != "0": - ok, err = tmux.send_digit(pane_id, int(k)) - elif k in _KEYS: - ok, err = tmux.send_key(pane_id, _KEYS[k]) + elif kl.isdigit() and len(kl) == 1 and kl != "0": + ok, err = tmux.send_digit(pane_id, int(kl)) # menu-option select else: - raise HTTPException(status_code=400, detail=f"key not allowed: {body.key!r}") + resolved = _resolve_key(k) + if resolved is None: + raise HTTPException(status_code=400, detail=f"key not allowed: {body.key!r}") + tk, literal = resolved + # Literal chars go as raw text (no Enter); everything else is a named/ + # modified key. + ok, err = ( + tmux.send_text(pane_id, tk, submit=False) if literal else tmux.send_key(pane_id, tk) + ) if not ok: raise HTTPException(status_code=400, detail=f"tmux: {err}") request.app.state.autopilot.store.log("tmux", "pane_key", f"{pane_id} ← {k}") @@ -234,3 +386,187 @@ def new_session(body: NewSession, request: Request) -> dict: raise HTTPException(status_code=400, detail=f"tmux: {result}") request.app.state.autopilot.store.log("tmux", "new_session", f"{result} in {cwd}") return {"ok": True, "pane_id": result} + + +# --- Launch profiles ------------------------------------------------------------- +# Named templates for opening a new window (cwd + shell command + optional params), +# hand-authored in ~/.muse/profiles.toml. muse reads them and launches; see profiles.py. + + +@router.get("/profiles") +def list_profiles() -> list[dict]: + """Every launch profile (built-in default first, then the user's TOML). A broken + config file is a 400 with the parse error so the UI can show it.""" + try: + return [p.model_dump() for p in profiles_mod.load_profiles()] + except profiles_mod.ProfileError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/profiles/{name}/launch") +def launch_profile(name: str, body: LaunchProfile, request: Request) -> dict: + """Open a new window from a profile: render its cwd/command with the collected + param values and run it via tmux, appended to the target group (or the default + session). Returns the new pane id so the UI can jump to it.""" + if not tmux.available(): + raise HTTPException(status_code=400, detail="tmux is not running") + try: + profile = profiles_mod.find_profile(name) + except profiles_mod.ProfileError as e: + raise HTTPException(status_code=400, detail=str(e)) + if profile is None: + raise HTTPException(status_code=404, detail=f"no such profile: {name!r}") + cwd, command = profiles_mod.render(profile, body.values) + if not os.path.isdir(cwd): + raise HTTPException(status_code=400, detail=f"not a directory: {cwd}") + session = _require_session_name(body.session) if body.session else _default_session() + label = profiles_mod.window_label(profile, body.values) + ok, result = tmux.new_window(cwd, command, session=session, name=label or None) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {result}") + request.app.state.autopilot.store.log( + "tmux", "profile_launch", f"{profile.name}: {result} in {cwd}" + ) + return {"ok": True, "pane_id": result} + + +# --- Session restore ------------------------------------------------------------- +# muse snapshots the tmux topology (see autopilot/snapshot.py) so it can be rebuilt after +# a reboot, resuming each Claude session where it left off (`claude --resume `). + + +class RestoreLayout(BaseModel): + groups: list[str] = [] # which snapshot groups (tmux sessions) to rebuild + + +@router.get("/snapshot") +def get_snapshot(request: Request) -> dict: + """The latest topology snapshot, annotated with per-window `live` flags and an `offer` + flag (the snapshot has Claude windows and none are currently running — the post-reboot + signal that drives the restore banner). Empty shape when nothing's captured yet.""" + latest = request.app.state.autopilot.store.latest_snapshot() + if latest is None: + return {"ts": None, "groups": [], "offer": False, "restorable_count": 0} + return layout_snapshot.annotate_liveness(json.loads(latest[1])) + + +@router.post("/restore") +def restore_layout(body: RestoreLayout, request: Request) -> dict: + """Rebuild the selected groups from the latest snapshot — recreate their tmux sessions/ + windows and resume each Claude session. Skips anything already running (no duplicates).""" + if not tmux.available(): + raise HTTPException(status_code=400, detail="tmux is not running") + latest = request.app.state.autopilot.store.latest_snapshot() + if latest is None: + raise HTTPException(status_code=400, detail="no layout snapshot to restore") + result = layout_snapshot.restore(json.loads(latest[1]), body.groups) + request.app.state.autopilot.store.log( + "tmux", "layout_restore", f"{result['restored']} windows in {result['groups']}" + ) + return {"ok": True, **result} + + +# --- Groups (tmux sessions) ------------------------------------------------------ +# A "group" is a tmux session. Panes are organized by moving whole windows between +# sessions; muse just runs the tmux verbs and the layout poll reflects the result. + + +@router.post("/sessions") +def create_group(body: GroupBody, request: Request) -> dict: + """Create a new empty group (detached tmux session). It carries one idle shell + until windows are moved into it.""" + if not tmux.available(): + raise HTTPException(status_code=400, detail="tmux is not running") + name = _require_session_name(body.name) + ok, err = tmux.new_session(name) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + request.app.state.autopilot.store.log("tmux", "group_create", name) + return {"ok": True, "session": name} + + +@router.post("/sessions/{name}/rename") +def rename_group(name: str, body: GroupBody, request: Request) -> dict: + """Rename a group (tmux session).""" + _require_session_name(name) + new = _require_session_name(body.name) + ok, err = tmux.rename_session(name, new) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + request.app.state.autopilot.store.log("tmux", "group_rename", f"{name} → {new}") + return {"ok": True, "session": new} + + +@router.delete("/sessions/{name}") +def delete_group(name: str, request: Request) -> dict: + """Destroy a group (tmux session) and everything running in it. The UI only + offers this for placeholder-only groups + a confirm.""" + _require_session_name(name) + ok, err = tmux.kill_session(name) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + request.app.state.autopilot.store.log("tmux", "group_delete", name) + return {"ok": True} + + +@router.post("/windows/{window_id}/rename") +def rename_window(window_id: str, body: GroupBody, request: Request) -> dict: + """Rename a window (the label shown in the list and deck tabs). Window names are + freeform, but reject empties / control chars / absurd lengths.""" + _require_window(window_id) + name = body.name.strip() + if not name or len(name) > 100 or any(c in name for c in "\n\r\t"): + raise HTTPException(status_code=400, detail=f"invalid window name: {body.name!r}") + ok, err = tmux.rename_window(window_id, name) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + request.app.state.autopilot.store.log("tmux", "window_rename", f"{window_id} → {name}") + return {"ok": True, "window_id": window_id, "name": name} + + +@router.post("/windows/{window_id}/move") +def move_window(window_id: str, body: MoveWindow, request: Request) -> dict: + """Move a whole window into another group (tmux session). The window's panes keep + running — a live Claude is undisturbed.""" + _require_window(window_id) + dst = _require_session_name(body.session) + ok, err = tmux.move_window(window_id, dst) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + request.app.state.autopilot.store.log("tmux", "window_move", f"{window_id} → {dst}") + return {"ok": True, "window_id": window_id, "session": dst} + + +@router.get("/windows/{window_id}/cleanup") +def window_cleanup(window_id: str) -> dict: + """Preview a window's removal: its name/cwd and, if a profile's match_cwd claims it, + the exact cleanup command that would run (so the Remove dialog can show it).""" + _require_window(window_id) + cwd, name = _window_repr(window_id) + match = profiles_mod.match_cleanup(cwd, name) + cleanup = {"profile": match["profile"], "command": match["command"]} if match else None + return {"window_name": name, "cwd": cwd, "cleanup": cleanup} + + +@router.post("/windows/{window_id}/close") +def close_window(window_id: str, body: CloseWindow, request: Request) -> dict: + """Remove a session: kill the window, then (opt-in) run its profile's cleanup. Kill + first so a teardown like `git worktree remove` isn't fighting a live cwd; cleanup is + best-effort — a failure is reported, not fatal (the window is already gone).""" + if not tmux.available(): + raise HTTPException(status_code=400, detail="tmux is not running") + _require_window(window_id) + cwd, name = _window_repr(window_id) + ok, err = tmux.kill_window(window_id) + if not ok: + raise HTTPException(status_code=400, detail=f"tmux: {err}") + store = request.app.state.autopilot.store + store.log("tmux", "window_close", f"{window_id} ({name})") + result = {"ok": True, "cleanup_ran": False, "cleanup_ok": False, "cleanup_output": ""} + if body.cleanup: + match = profiles_mod.match_cleanup(cwd, name) + if match: + c_ok, out = profiles_mod.run_cleanup(match["command"], match["run_cwd"]) + result.update(cleanup_ran=True, cleanup_ok=c_ok, cleanup_output=out) + store.log("tmux", "window_cleanup", f"{name}: {'ok' if c_ok else 'FAILED'}") + return result diff --git a/backend/muse/runway.py b/backend/muse/runway.py new file mode 100644 index 0000000..788fd44 --- /dev/null +++ b/backend/muse/runway.py @@ -0,0 +1,157 @@ +"""Budget runway: how much headroom is left in the current 5-hour / weekly +rate-limit windows, who's burning it, and when you'll hit the wall. + +This is the fleet-driving question ("can I keep all these sessions running, or +do I need to pause one?") answered cheaply enough for the phone cockpit to poll: +one pass over the (mtime-cached) usage scan, TTL-cached for _TTL seconds. + +The window math mirrors stats.compute_stats: the 5h window anchors to an +OBSERVED reset when autopilot ever parsed one (reset + k*5h), else falls back to +the trailing 5 hours; the weekly window is trailing 7 days. + +Budgets are honest or absent. Subscription plans (Team/Max) have no published $ +caps, so unlike the stats page this deliberately does NOT use plan.py's rough +tier estimates: a budget is shown only when configured (MUSE_LIMIT_*_USD) or +OBSERVED — the window's spend the last time a limit banner was actually seen +(recorded via usage_history.record_limit_hit). Otherwise the runway reports +spend, burn, and reset time with no made-up ceiling. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone +from typing import Optional + +from . import discovery +from .config import get_settings +from .models import RunwayResponse, RunwaySession, RunwayWindow +from .plan import detect_plan +from .pricing import cost_usd +from .usage_cache import scan_all + +HOUR5_SECONDS = 5 * 3600 +WEEK_SECONDS = 7 * 86400 +_BURN_LOOKBACK_SECONDS = 30 * 60 # trailing burn-rate sample +_TTL = 10.0 + +_cache: tuple[float, Optional[RunwayResponse]] = (0.0, None) + + +def five_hour_window_start(now: datetime, history) -> tuple[datetime, str]: + """Window start + anchor source ("reset" when derived from an observed + usage-limit reset, else "estimated" trailing-5h).""" + if history is not None: + try: + reset = history.latest_reset() + except Exception: + reset = None + if reset is not None: + k = (now - reset).total_seconds() // HOUR5_SECONDS + start = reset + timedelta(seconds=k * HOUR5_SECONDS) + if start <= now: + return start, "reset" + return now - timedelta(seconds=HOUR5_SECONDS), "estimated" + + +def _resolve_budget(env_value: Optional[float], history, kind: str + ) -> tuple[Optional[float], str]: + """(budget, source): configured env override > observed ceiling > none.""" + if env_value is not None: + return env_value, "configured" + ceiling = None + if history is not None: + try: + ceiling = history.observed_ceiling(kind) + except Exception: + ceiling = None + if ceiling: + return ceiling, "observed" + return None, "none" + + +def compute_runway(history=None, now: Optional[datetime] = None) -> RunwayResponse: + now = now or datetime.now(timezone.utc) + settings = get_settings() + plan = detect_plan(settings.limit_5h_usd, settings.limit_week_usd) + budget_5h, src_5h = _resolve_budget(settings.limit_5h_usd, history, "5h") + budget_week, src_week = _resolve_budget(settings.limit_week_usd, history, "week") + + start_5h, anchor_source = five_hour_window_start(now, history) + end_5h = start_5h + timedelta(seconds=HOUR5_SECONDS) + start_week = now - timedelta(seconds=WEEK_SECONDS) + burn_cut = now - timedelta(seconds=_BURN_LOOKBACK_SECONDS) + + spent_5h = spent_week = burn_sample = 0.0 + per_session: dict[str, float] = {} + for e in scan_all().events: + if e.ts is None or e.total <= 0 or e.ts < start_week: + continue + cost = cost_usd(e.model, e.input, e.output, e.cc, e.cr) + spent_week += cost + if e.ts >= start_5h: + spent_5h += cost + per_session[e.sid] = per_session.get(e.sid, 0.0) + cost + if e.ts >= burn_cut: + burn_sample += cost + + burn_per_hour = burn_sample * (3600 / _BURN_LOOKBACK_SECONDS) + + projected: Optional[datetime] = None + exhaust_before_reset = False + if budget_5h: + remaining_usd = budget_5h - spent_5h + if remaining_usd <= 0: + projected = now + exhaust_before_reset = True + elif burn_per_hour > 0: + projected = now + timedelta(hours=remaining_usd / burn_per_hour) + exhaust_before_reset = projected < end_5h + + titles = {s.session_id: s.title for s in discovery.list_sessions()} + top = sorted(per_session.items(), key=lambda kv: kv[1], reverse=True)[:5] + + def window(label: str, seconds: int, start: datetime, spent: float, + budget: Optional[float], budget_source: str, source: str) -> RunwayWindow: + elapsed = max(0, min(seconds, int((now - start).total_seconds()))) + return RunwayWindow( + label=label, + window_seconds=seconds, + anchor=start, + anchor_source=source, + elapsed_seconds=elapsed, + remaining_seconds=seconds - elapsed, + cost_usd=round(spent, 4), + budget_usd=round(budget, 2) if budget else None, + budget_source=budget_source, + pct_used=round(spent / budget, 4) if budget else None, + pct_elapsed=round(elapsed / seconds, 4), + ) + + return RunwayResponse( + generated_at=now, + plan_label=plan.label if plan else None, + five_hour=window("5-hour window", HOUR5_SECONDS, start_5h, spent_5h, + budget_5h, src_5h, anchor_source), + week=window("Weekly window", WEEK_SECONDS, start_week, spent_week, + budget_week, src_week, "estimated"), + burn_usd_per_hour=round(burn_per_hour, 4), + projected_exhaust_at=projected, + exhaust_before_reset=exhaust_before_reset, + top_sessions=[ + RunwaySession(session_id=sid, title=titles.get(sid, sid[:8]), + cost_usd=round(c, 4)) + for sid, c in top + ], + ) + + +def get_runway(history=None) -> RunwayResponse: + """TTL-cached wrapper — safe for the cockpit's poll cadence.""" + global _cache + ts, cached = _cache + if cached is not None and time.monotonic() - ts < _TTL: + return cached + fresh = compute_runway(history) + _cache = (time.monotonic(), fresh) + return fresh diff --git a/backend/muse/slash_commands.py b/backend/muse/slash_commands.py new file mode 100644 index 0000000..5c43ebe --- /dev/null +++ b/backend/muse/slash_commands.py @@ -0,0 +1,113 @@ +"""Enumerate the slash commands available to a Claude Code session, so the phone +composer can offer a searchable "/" menu like the CLI does. + +There is no API that lists them, so we reconstruct the set from the same sources +Claude Code reads: a curated list of built-ins, the user's global command files +(~/.claude/commands/**.md), and the project's command files ({cwd}/.claude/ +commands/**.md). Everything here is READ-ONLY — we never touch ~/.claude. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Built-in Claude Code slash commands. Kept short-and-common on purpose — the +# custom (user/project) commands are the ones worth discovering; the built-ins +# are just here so the menu isn't empty and the familiar names still complete. +_BUILTINS: list[tuple[str, str]] = [ + ("add-dir", "Add a new working directory"), + ("agents", "Manage custom subagents"), + ("clear", "Clear conversation history"), + ("compact", "Summarize and compact the conversation"), + ("config", "Open the config panel"), + ("context", "Visualize current context usage"), + ("cost", "Show token usage and cost"), + ("doctor", "Diagnose the Claude Code installation"), + ("help", "List available commands"), + ("init", "Initialize a CLAUDE.md for this project"), + ("mcp", "Manage MCP server connections"), + ("memory", "Edit Claude memory files"), + ("model", "Select the active model"), + ("permissions", "View or edit tool permissions"), + ("pr-comments", "Fetch comments from a GitHub PR"), + ("resume", "Resume a previous conversation"), + ("review", "Review a pull request"), + ("rewind", "Rewind the conversation to an earlier point"), + ("status", "Show account and system status"), + ("terminal-setup", "Install terminal key bindings"), + ("vim", "Toggle vim editing mode"), +] + +_MAX_DESC = 100 +# A sane ceiling so a pathological commands tree can't stall the poll. +_MAX_FILES = 400 + + +def _description(path: Path) -> str: + """Best-effort one-liner for a command file: the frontmatter `description:` if + present, else the first meaningful line of the body.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + lines = text.splitlines() + # YAML frontmatter: a leading `---` block. Pull `description:` out of it. + if lines and lines[0].strip() == "---": + for ln in lines[1:]: + if ln.strip() == "---": + break + key, sep, val = ln.partition(":") + if sep and key.strip().lower() == "description": + return val.strip().strip("'\"")[:_MAX_DESC] + # Otherwise the first non-blank line of the body (skip frontmatter + headings). + in_fm = bool(lines) and lines[0].strip() == "---" + for ln in lines: + s = ln.strip() + if in_fm: + if s == "---": + in_fm = False + continue + if s and not s.startswith("---"): + return s.lstrip("# ").strip()[:_MAX_DESC] + return "" + + +def _scan(base: Path, source: str) -> list[dict]: + """All *.md command files under base/, named by their path relative to base + with directories joined by ':' (Claude Code's namespacing).""" + out: list[dict] = [] + if not base.is_dir(): + return out + count = 0 + for path in sorted(base.rglob("*.md")): + if not path.is_file(): + continue + count += 1 + if count > _MAX_FILES: + break + rel = path.relative_to(base).with_suffix("") + name = ":".join(rel.parts) + if not name: + continue + out.append({"name": name, "description": _description(path), "source": source}) + return out + + +def list_commands(cwd: str | None) -> list[dict]: + """The slash commands available in a session rooted at `cwd`: built-ins, the + user's global commands, then the project's. A more-specific definition wins on + a name collision (project > user > builtin), and the result is sorted by name.""" + by_name: dict[str, dict] = {} + for name, desc in _BUILTINS: + by_name[name] = {"name": name, "description": desc, "source": "builtin"} + + home = os.path.expanduser("~") + if home: + for c in _scan(Path(home) / ".claude" / "commands", "user"): + by_name[c["name"]] = c + if cwd: + for c in _scan(Path(cwd) / ".claude" / "commands", "project"): + by_name[c["name"]] = c + + return sorted(by_name.values(), key=lambda c: c["name"]) diff --git a/backend/muse/usage_cache.py b/backend/muse/usage_cache.py index 83fe3c8..05c4d4d 100644 --- a/backend/muse/usage_cache.py +++ b/backend/muse/usage_cache.py @@ -143,21 +143,33 @@ def _agent_type(sub_path: Path) -> str: return at +def _window_for_peak(peak: int) -> int: + """Context window inferred from the largest single-request prompt seen for a + model: any prompt over 200k proves the 1M-token window is in use (you can't + send 250k tokens to a 200k model), so that model runs on 1M.""" + return 1_000_000 if peak > 200_000 else 200_000 + + def context_pcts(scan: Scan) -> dict[str, float]: """Latest main-thread context size as a % of the model window, per session. + The window is decided PER MODEL (not per session): the largest prompt any + session sent to a given model tells us that model's window, so a session that + simply hasn't crossed 200k yet is still measured against the right window + instead of reading ~5x too high against an assumed 200k. (Lifted out of AutopilotController so the board ticker shares it.)""" - latest: dict[str, tuple[datetime, int]] = {} - peak: dict[str, int] = {} + latest: dict[str, tuple[datetime, int, str]] = {} + model_peak: dict[str, int] = {} for e in scan.events: if e.is_subagent or e.ts is None or e.context <= 0: continue cur = latest.get(e.sid) if cur is None or e.ts > cur[0]: - latest[e.sid] = (e.ts, e.context) - peak[e.sid] = max(peak.get(e.sid, 0), e.context) + latest[e.sid] = (e.ts, e.context, e.model or "") + m = e.model or "" + model_peak[m] = max(model_peak.get(m, 0), e.context) out = {} - for sid, (_ts, ctx) in latest.items(): - window = 1_000_000 if peak[sid] > 200_000 else 200_000 + for sid, (_ts, ctx, model) in latest.items(): + window = _window_for_peak(model_peak.get(model, 0)) out[sid] = 100.0 * ctx / window return out @@ -186,15 +198,16 @@ def _file_aggregate(path: Path, events: list[Event]) -> tuple: cost = 0.0 latest_ts = None latest_ctx = 0 + latest_model = "" peak_ctx = 0 for e in events: tokens += e.input + e.output + e.cc # real work (excl. cache reads) cost += cost_usd(e.model, e.input, e.output, e.cc, e.cr) if not e.is_subagent and e.ts is not None and e.context > 0: if latest_ts is None or e.ts > latest_ts: - latest_ts, latest_ctx = e.ts, e.context + latest_ts, latest_ctx, latest_model = e.ts, e.context, e.model or "" peak_ctx = max(peak_ctx, e.context) - agg = (mtime, sid, tokens, cost, latest_ts, latest_ctx, peak_ctx) + agg = (mtime, sid, tokens, cost, latest_ts, latest_ctx, peak_ctx, latest_model) _file_agg_cache[path] = agg return agg @@ -204,7 +217,7 @@ def board_rollup() -> tuple[dict[str, tuple[int, float]], dict[str, float]]: O(#files) per call thanks to the per-file mtime-keyed aggregate cache.""" projects = get_settings().projects_dir aggs: dict[str, tuple[int, float]] = {} - latest: dict[str, tuple] = {} # sid -> (ts, ctx) + latest: dict[str, tuple] = {} # sid -> (ts, ctx, model) peak: dict[str, int] = {} if not projects.is_dir(): return aggs, {} @@ -216,7 +229,7 @@ def feed(path: Path, sid: str, project: str, is_sub: bool, atype: str, aid: str if a[4] is not None: cur = latest.get(sid) if cur is None or a[4] > cur[0]: - latest[sid] = (a[4], a[5]) + latest[sid] = (a[4], a[5], a[7]) peak[sid] = max(peak.get(sid, 0), a[6]) for project_dir in projects.iterdir(): @@ -228,10 +241,14 @@ def feed(path: Path, sid: str, project: str, is_sub: bool, atype: str, aid: str feed(sub, sub.parent.parent.name, project_dir.name, True, _agent_type(sub), sub.stem) + # Window per MODEL: the biggest prompt any session sent to a model reveals + # that model's window, so low-usage sessions aren't measured against 200k. + model_peak: dict[str, int] = {} + for sid, (_ts, _ctx, model) in latest.items(): + model_peak[model] = max(model_peak.get(model, 0), peak.get(sid, 0)) pcts = {} - for sid, (_ts, ctx) in latest.items(): - window = 1_000_000 if peak.get(sid, 0) > 200_000 else 200_000 - pcts[sid] = 100.0 * ctx / window + for sid, (_ts, ctx, model) in latest.items(): + pcts[sid] = 100.0 * ctx / _window_for_peak(model_peak.get(model, 0)) return aggs, pcts diff --git a/backend/muse/usage_history.py b/backend/muse/usage_history.py index 28f2c04..842084a 100644 --- a/backend/muse/usage_history.py +++ b/backend/muse/usage_history.py @@ -15,7 +15,7 @@ import sqlite3 import threading -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Callable, Iterable, Optional @@ -28,6 +28,11 @@ resets_at TEXT PRIMARY KEY, -- UTC ISO; observed 5h-window reset boundary observed_at TEXT ); +CREATE TABLE IF NOT EXISTS limit_hit ( + observed_at TEXT PRIMARY KEY, -- local ISO; when a usage-limit banner was seen + kind TEXT NOT NULL DEFAULT '5h', -- '5h' | 'week' + window_cost REAL NOT NULL -- spend in that window at the moment of the hit +); CREATE TABLE IF NOT EXISTS usage_daily ( day TEXT NOT NULL, -- local YYYY-MM-DD model TEXT NOT NULL, @@ -147,6 +152,50 @@ def latest_reset(self) -> Optional[datetime]: except ValueError: return None + # --- observed limit ceilings ------------------------------------------------- + # Subscription plans have no published $ caps, so the honest budget is the + # spend level at which the wall was actually hit. Each banner sighting records + # the window's spend; the recent MAX is the effective ceiling. + + def record_limit_hit(self, kind: str, window_cost: float, + dedupe_minutes: int = 30) -> bool: + """Record one observed limit hit. Banners persist across ticks, so hits of + the same kind within `dedupe_minutes` are one sighting. Returns True if + recorded.""" + now = datetime.now().astimezone() + with self._lock: + r = self._conn.execute( + "SELECT MAX(observed_at) AS m FROM limit_hit WHERE kind=?", (kind,) + ).fetchone() + if r and r["m"]: + try: + last = datetime.fromisoformat(r["m"]) + if (now - last).total_seconds() < dedupe_minutes * 60: + return False + except ValueError: + pass + + def work(): + self._conn.execute( + "INSERT OR IGNORE INTO limit_hit(observed_at, kind, window_cost) " + "VALUES(?,?,?)", + (now.isoformat(), kind, float(window_cost)), + ) + self._write(work) + return True + + def observed_ceiling(self, kind: str = "5h", days: int = 30) -> Optional[float]: + """Best estimate of the window's real cap: the highest spend that ever hit + the wall recently (the cap is at least that high).""" + cutoff = (datetime.now().astimezone() - timedelta(days=days)).isoformat() + with self._lock: + r = self._conn.execute( + "SELECT MAX(window_cost) AS m FROM limit_hit " + "WHERE kind=? AND observed_at >= ?", + (kind, cutoff), + ).fetchone() + return float(r["m"]) if r and r["m"] is not None else None + def day_count(self) -> int: with self._lock: r = self._conn.execute("SELECT COUNT(DISTINCT day) AS n FROM usage_daily").fetchone() diff --git a/backend/tests/test_alerts_hooks.py b/backend/tests/test_alerts_hooks.py new file mode 100644 index 0000000..8221239 --- /dev/null +++ b/backend/tests/test_alerts_hooks.py @@ -0,0 +1,96 @@ +"""Hook-driven alert paths on AlertsWatcher: instant fire, settle-and-recheck, +reply cancellation, and tick suppression.""" + +import asyncio +from datetime import datetime, timezone + +import pytest + +from muse import alerts +from muse.models import AlertRules, LiveSession, NotifyConfig, NotifyResult, SessionSummary + + +class FakeService: + def __init__(self): + self.sent = [] + self.rules = AlertRules() + self.cfg = NotifyConfig(enabled=True, topic="t") + + def get_alert_rules(self): + return self.rules + + def get_notify_config(self): + return self.cfg + + def list_sessions(self): + return [SessionSummary(session_id="s", project_dir="-p", title="My sess", + mtime=datetime.now(timezone.utc), state="waiting")] + + def send_notification(self, message, **kw): + self.sent.append((message, kw)) + return NotifyResult(ok=True, detail="HTTP 200") + + +@pytest.fixture +def watcher(monkeypatch): + monkeypatch.setattr(alerts, "_TURN_END_SETTLE_SECONDS", 0.0) + monkeypatch.setattr( + alerts.live_discovery, "discover", + lambda: [LiveSession(session_id="s", pid=1, status="idle", pane_id="%1")], + ) + return alerts.AlertsWatcher(FakeService()) + + +def test_needs_you_fires_once_per_pause(watcher): + asyncio.run(watcher.hook_needs_you("s", "Claude needs your permission to use Bash")) + asyncio.run(watcher.hook_needs_you("s", "Claude needs your permission to use Bash")) + assert len(watcher.service.sent) == 1 + title, kw = watcher.service.sent[0] + assert "needs you" in kw["title"] and "/drive/s" in kw["click"] + # the polling tick is suppressed for the same transition + assert watcher._suppressed("s", "waiting") is True + + +def test_turn_ended_fires_when_still_idle(watcher): + asyncio.run(watcher.hook_turn_ended("s")) + assert len(watcher.service.sent) == 1 + assert "ready for you" in watcher.service.sent[0][1]["title"] + + +def test_turn_ended_cancelled_by_user_reply(watcher, monkeypatch): + monkeypatch.setattr(alerts, "_TURN_END_SETTLE_SECONDS", 0.05) + + async def scenario(): + t = asyncio.create_task(watcher.hook_turn_ended("s")) + await asyncio.sleep(0.01) + watcher.hook_user_replied("s") # user answered during the settle window + await t + + asyncio.run(scenario()) + assert watcher.service.sent == [] + + +def test_turn_ended_skipped_when_session_moved_on(watcher, monkeypatch): + monkeypatch.setattr( + alerts.live_discovery, "discover", + lambda: [LiveSession(session_id="s", pid=1, status="busy", pane_id="%1")], + ) + asyncio.run(watcher.hook_turn_ended("s")) + assert watcher.service.sent == [] + + +def test_session_end_respects_rule(watcher): + asyncio.run(watcher.hook_session_end("s")) # on_stopped defaults False + assert watcher.service.sent == [] + watcher.service.rules = AlertRules(on_stopped=True) + asyncio.run(watcher.hook_session_end("s")) + assert len(watcher.service.sent) == 1 + asyncio.run(watcher.hook_session_end("s")) # deduped + assert len(watcher.service.sent) == 1 + + +def test_rules_off_means_silent(watcher): + watcher.service.rules = AlertRules(on_waiting=False) + asyncio.run(watcher.hook_needs_you("s", "m")) + asyncio.run(watcher.hook_turn_ended("s")) + assert watcher.service.sent == [] diff --git a/backend/tests/test_context_pct.py b/backend/tests/test_context_pct.py new file mode 100644 index 0000000..7c3a32f --- /dev/null +++ b/backend/tests/test_context_pct.py @@ -0,0 +1,73 @@ +"""Context-window occupancy %. The window is inferred PER MODEL from the largest +prompt seen for that model, so two sessions on the same model share one window — +a low-usage session must not read ~5x too high against an assumed 200k.""" + +from datetime import datetime, timezone + +from muse.usage_cache import Event, Scan, _window_for_peak, context_pcts + + +def _ev(sid, ctx, model, secs): + # context = input + cc + cr; put it all in cache_read for simplicity. + return Event( + sid=sid, + project_dir="p", + ts=datetime(2026, 7, 4, 0, 0, secs, tzinfo=timezone.utc), + input=0, + output=10, + cc=0, + cr=ctx, + model=model, + is_subagent=False, + agent_type="", + ) + + +def test_window_for_peak_buckets_at_200k(): + assert _window_for_peak(150_000) == 200_000 + assert _window_for_peak(200_000) == 200_000 + assert _window_for_peak(200_001) == 1_000_000 + + +def test_same_model_shares_a_window(): + # One opus session crossed 200k (proving a 1M window); another opus session + # is only at 100k. Both must be measured against 1M — the small one ~10%, + # NOT 50% against a wrongly-assumed 200k. + scan = Scan( + events=[ + _ev("big", 300_000, "opus", 1), + _ev("small", 100_000, "opus", 2), + ], + sessions=2, + sessions_by_project={}, + ) + pct = context_pcts(scan) + assert round(pct["big"]) == 30 + assert round(pct["small"]) == 10 + + +def test_distinct_models_get_distinct_windows(): + # A model that never exceeded 200k keeps the 200k window. + scan = Scan( + events=[ + _ev("a", 300_000, "opus", 1), # opus → 1M + _ev("b", 100_000, "haiku", 2), # haiku stayed small → 200k + ], + sessions=2, + sessions_by_project={}, + ) + pct = context_pcts(scan) + assert round(pct["a"]) == 30 # 300k / 1M + assert round(pct["b"]) == 50 # 100k / 200k + + +def test_latest_event_wins_per_session(): + scan = Scan( + events=[ + _ev("s", 50_000, "haiku", 1), + _ev("s", 120_000, "haiku", 5), # later → this is the current context + ], + sessions=1, + sessions_by_project={}, + ) + assert round(context_pcts(scan)["s"]) == 60 # 120k / 200k diff --git a/backend/tests/test_hooks_api.py b/backend/tests/test_hooks_api.py new file mode 100644 index 0000000..fdc223e --- /dev/null +++ b/backend/tests/test_hooks_api.py @@ -0,0 +1,100 @@ +"""Hook ingestion endpoint + event dispatch (fast: all delays zeroed).""" + +import asyncio +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from muse.routers import hooks as hooks_router + + +class FakeAlerts: + def __init__(self): + self.calls = [] + + def hook_user_replied(self, sid): + self.calls.append(("user_replied", sid)) + + async def hook_needs_you(self, sid, message): + self.calls.append(("needs_you", sid, message)) + + async def hook_turn_ended(self, sid): + self.calls.append(("turn_ended", sid)) + + async def hook_session_end(self, sid): + self.calls.append(("session_end", sid)) + + +def _state(delivered): + return SimpleNamespace( + alerts=FakeAlerts(), + autopilot=SimpleNamespace(deliver_queued=lambda sid: delivered), + hook_events=None, + ) + + +@pytest.fixture(autouse=True) +def fast(monkeypatch): + monkeypatch.setattr(hooks_router, "_QUEUE_DELIVER_DELAY", 0.0) + + +def _dispatch(state, event, sid="a" * 36, payload=None): + asyncio.run(hooks_router._dispatch(state, event, sid, payload or {})) + + +def test_stop_delivers_queue_and_skips_alert(): + state = _state(delivered=[7]) + _dispatch(state, "Stop") + assert state.alerts.calls == [("user_replied", "a" * 36)] + + +def test_stop_with_empty_queue_alerts(): + state = _state(delivered=[]) + _dispatch(state, "Stop") + assert state.alerts.calls == [("turn_ended", "a" * 36)] + + +def test_notification_and_prompt_and_end(): + state = _state(delivered=[]) + _dispatch(state, "Notification", payload={"message": "needs permission for Bash"}) + _dispatch(state, "UserPromptSubmit") + _dispatch(state, "SessionEnd") + _dispatch(state, "SubagentStop") # recorded upstream, no action + assert state.alerts.calls == [ + ("needs_you", "a" * 36, "needs permission for Bash"), + ("user_replied", "a" * 36), + ("session_end", "a" * 36), + ] + + +def test_endpoint_records_and_never_errors(monkeypatch): + app = FastAPI() + app.include_router(hooks_router.router) + dispatched = [] + + async def fake_dispatch(state, event, sid, payload): + dispatched.append((event, sid)) + + monkeypatch.setattr(hooks_router, "_dispatch", fake_dispatch) + app.state.alerts = FakeAlerts() + app.state.autopilot = SimpleNamespace(deliver_queued=lambda sid: []) + c = TestClient(app) + + sid = "0f0e0d0c-1111-2222-3333-444455556666" + r = c.post("/api/hooks/claude", json={"hook_event_name": "Stop", "session_id": sid}) + assert r.status_code == 200 and r.json()["ok"] is True + + # malformed payloads are acknowledged, not errored (the relay must never break) + r = c.post("/api/hooks/claude", content=b"not json", + headers={"Content-Type": "application/json"}) + assert r.status_code == 200 and r.json()["ok"] is False + r = c.post("/api/hooks/claude", json={"session_id": "../../etc/passwd", + "hook_event_name": "Stop"}) + assert r.status_code == 200 # bad sid: recorded but not dispatched + + st = c.get("/api/hooks/status").json() + assert st["events_seen"] == 2 # the two parseable payloads + assert st["last_event_at"] is not None + assert dispatched == [("Stop", sid)] diff --git a/backend/tests/test_hooksetup.py b/backend/tests/test_hooksetup.py new file mode 100644 index 0000000..b65e312 --- /dev/null +++ b/backend/tests/test_hooksetup.py @@ -0,0 +1,52 @@ +"""Hook installer: conservative settings.json merge, idempotence, uninstall.""" + +from muse import hooksetup + +CMD = "/home/u/.muse/hook.sh" + +RTK = {"matcher": "Bash", "hooks": [{"type": "command", "command": "rtk hook claude"}]} + + +def test_add_registers_all_events_and_preserves_existing(): + settings = { + "model": "claude-fable-5", + "hooks": {"PreToolUse": [RTK], "Stop": [{"hooks": [{"type": "command", "command": "other"}]}]}, + } + out = hooksetup.add_muse_hooks(settings, CMD) + # untouched keys and foreign hooks survive + assert out["model"] == "claude-fable-5" + assert out["hooks"]["PreToolUse"] == [RTK] + assert out["hooks"]["Stop"][0]["hooks"][0]["command"] == "other" + # ours added for every event + for event in hooksetup.HOOK_EVENTS: + cmds = [ + h["command"] + for m in out["hooks"][event] + for h in m.get("hooks", []) + ] + assert CMD in cmds + # input not mutated + assert "Notification" not in settings["hooks"] + + +def test_add_is_idempotent(): + once = hooksetup.add_muse_hooks({}, CMD) + twice = hooksetup.add_muse_hooks(once, CMD) + assert once == twice + + +def test_remove_strips_only_ours(): + merged = hooksetup.add_muse_hooks( + {"hooks": {"PreToolUse": [RTK]}, "theme": "dark"}, CMD + ) + out = hooksetup.remove_muse_hooks(merged, CMD) + assert out["hooks"] == {"PreToolUse": [RTK]} + assert out["theme"] == "dark" + # removing from a settings dict with no hooks is a no-op + assert hooksetup.remove_muse_hooks({"a": 1}, CMD) == {"a": 1} + + +def test_remove_then_add_roundtrip(): + base = {"hooks": {"SessionEnd": [{"hooks": [{"type": "command", "command": "notify.cjs"}]}]}} + merged = hooksetup.add_muse_hooks(base, CMD) + assert hooksetup.remove_muse_hooks(merged, CMD) == base diff --git a/backend/tests/test_limit_ceiling.py b/backend/tests/test_limit_ceiling.py new file mode 100644 index 0000000..0cbc906 --- /dev/null +++ b/backend/tests/test_limit_ceiling.py @@ -0,0 +1,35 @@ +"""Observed limit ceilings: record/dedupe/read, and the controller's on_limit hook.""" + +import pytest + +from muse.usage_history import UsageHistoryStore + + +@pytest.fixture +def store(tmp_path): + s = UsageHistoryStore(tmp_path / "h.db") + yield s + s.close() + + +def test_record_and_ceiling(store): + assert store.observed_ceiling("5h") is None + assert store.record_limit_hit("5h", 62.5) is True + assert store.observed_ceiling("5h") == 62.5 + assert store.observed_ceiling("week") is None # kinds are independent + + +def test_dedupe_within_window(store): + assert store.record_limit_hit("5h", 60.0) is True + # The banner persists across ticks — same sighting, not a new data point. + assert store.record_limit_hit("5h", 61.0, dedupe_minutes=30) is False + assert store.observed_ceiling("5h") == 60.0 + # A different kind is a separate sighting. + assert store.record_limit_hit("week", 700.0) is True + + +def test_ceiling_is_recent_max(store): + store.record_limit_hit("5h", 55.0, dedupe_minutes=0) + store.record_limit_hit("5h", 71.0, dedupe_minutes=0) + store.record_limit_hit("5h", 63.0, dedupe_minutes=0) + assert store.observed_ceiling("5h") == 71.0 diff --git a/backend/tests/test_pane_keys.py b/backend/tests/test_pane_keys.py new file mode 100644 index 0000000..404bb5a --- /dev/null +++ b/backend/tests/test_pane_keys.py @@ -0,0 +1,46 @@ +"""The pane key bar sends named keys, literal characters, modifier combos, and +function keys through /api/tmux/panes/{id}/key. _resolve_key is the allowlist + +normalizer that turns a UI key string into a tmux send-keys argument.""" + +import pytest + +from muse.routers.tmux import _resolve_key + + +@pytest.mark.parametrize( + "raw,expected", + [ + # named navigation keys → tmux key names, sent as keys (not literal) + ("escape", ("Escape", False)), + ("tab", ("Tab", False)), + ("left", ("Left", False)), + ("right", ("Right", False)), + ("home", ("Home", False)), + ("end", ("End", False)), + ("pageup", ("PageUp", False)), + ("pagedown", ("PageDown", False)), + ("delete", ("DC", False)), # Delete key (terminal passthrough) + # printable characters → sent literally (send-keys -l) + ("/", ("/", True)), + ("|", ("|", True)), + ("-", ("-", True)), + # modifier combos + ("c-c", ("C-c", False)), + ("m-f", ("M-f", False)), + ("m-b", ("M-b", False)), # Alt+char passthrough → meta + ("c-left", ("C-Left", False)), + ("c-m-a", ("C-M-a", False)), + ("s-tab", ("S-Tab", False)), # Shift+Tab passthrough + ("c-space", ("C-Space", False)), # Ctrl+Space passthrough + # function keys + ("f1", ("F1", False)), + ("f12", ("F12", False)), + ], +) +def test_resolve_key_allowed(raw, expected): + assert _resolve_key(raw) == expected + + +@pytest.mark.parametrize("raw", ["", "nope", "f13", "c-", "ctrl", "arrowup"]) +def test_resolve_key_rejects(raw): + assert _resolve_key(raw) is None diff --git a/backend/tests/test_profiles.py b/backend/tests/test_profiles.py new file mode 100644 index 0000000..f885ebc --- /dev/null +++ b/backend/tests/test_profiles.py @@ -0,0 +1,282 @@ +"""Launch profiles: hand-authored templates (~/.muse/profiles.toml) for opening a new +tmux window. profiles.load_profiles parses + validates the TOML (built-in default first); +render() substitutes {key} params (shell-quoted into the command, raw into the cwd); the +router launches via tmux.new_window into the current group (or the default session).""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from muse import profiles +from muse.routers import tmux as tmux_router + + +@pytest.fixture +def profiles_file(tmp_path, monkeypatch): + """Point profiles.toml at a tmp file; return a writer for the test to fill in.""" + path = tmp_path / "profiles.toml" + monkeypatch.setenv("MUSE_PROFILES_PATH", str(path)) + + def write(text: str) -> None: + path.write_text(text) + + write.path = path # type: ignore[attr-defined] + return write + + +# --- load_profiles ---------------------------------------------------------------- + + +def test_no_file_yields_only_builtin_default(profiles_file): + got = profiles.load_profiles() + assert [p.name for p in got] == ["Claude"] + assert got[0].builtin and got[0].command == "claude" + + +def test_parses_profiles_default_first_in_file_order(profiles_file): + profiles_file( + """ + [[profile]] + name = "igloo dev" + cwd = "~/workspace/igloo-dev" + command = "./dev.sh {issue} && claude" + params = [{ key = "issue", prompt = "Issue name" }] + + [[profile]] + name = "web" + cwd = "~/workspace/web" + command = "npm run dev & claude" + """ + ) + got = profiles.load_profiles() + assert [p.name for p in got] == ["Claude", "igloo dev", "web"] + igloo = got[1] + assert igloo.params[0].key == "issue" and igloo.params[0].default == "" + assert not igloo.builtin + + +def test_file_profile_named_claude_overrides_builtin(profiles_file): + profiles_file( + """ + [[profile]] + name = "Claude" + cwd = "~/work" + command = "claude --resume" + """ + ) + got = profiles.load_profiles() + assert [p.name for p in got] == ["Claude"] # not duplicated + assert got[0].command == "claude --resume" and not got[0].builtin + + +@pytest.mark.parametrize( + "text", + [ + "this is = not valid toml [[", # decode error + 'profile = "not an array"', # profile is not a list of tables + '[[profile]]\nname = "a"\n[[profile]]\nname = "A"', # duplicate (case-insensitive) + '[[profile]]\nname = "a"\nbogus = "x"', # unknown key (extra="forbid") + "[[profile]]\ncwd = \"~\"", # missing required name + ], +) +def test_bad_config_raises_profile_error(profiles_file, text): + profiles_file(text) + with pytest.raises(profiles.ProfileError): + profiles.load_profiles() + + +# --- render ----------------------------------------------------------------------- + + +def test_render_quotes_command_params_and_leaves_cwd_raw(): + p = profiles.Profile( + name="p", + cwd="~/w/{repo}", + command="./dev.sh {issue} && claude", + params=[ + profiles.ProfileParam(key="repo", prompt="Repo"), + profiles.ProfileParam(key="issue", prompt="Issue"), + ], + ) + cwd, command = profiles.render(p, {"repo": "my proj", "issue": "bug 42; rm -rf"}) + # cwd substituted raw (tmux -c is a literal arg, not shell-interpreted) + ~ expanded. + assert cwd.endswith("/w/my proj") and not cwd.startswith("~") + # command value is shell-quoted into a single safe token. + assert command == "./dev.sh 'bug 42; rm -rf' && claude" + + +def test_render_missing_value_falls_back_to_default(): + p = profiles.Profile( + name="p", + command="echo {greet}", + params=[profiles.ProfileParam(key="greet", prompt="Greeting", default="hi")], + ) + _, command = profiles.render(p, {}) + assert command == "echo hi" + + +# --- window_label ----------------------------------------------------------------- + + +def test_window_label_defaults_to_first_param_value(): + p = profiles.Profile( + name="igloo dev", + params=[profiles.ProfileParam(key="issue", prompt="Issue")], + ) + assert profiles.window_label(p, {"issue": "PROJ-12"}) == "PROJ-12" + + +def test_window_label_falls_back_to_profile_name_without_params(): + assert profiles.window_label(profiles.Profile(name="web"), {}) == "web" + + +def test_window_label_honors_explicit_template_and_truncates(): + p = profiles.Profile( + name="p", + window_name="igloo/{issue}", + params=[profiles.ProfileParam(key="issue", prompt="Issue")], + ) + assert profiles.window_label(p, {"issue": "abc"}) == "igloo/abc" + assert len(profiles.window_label(p, {"issue": "x" * 100})) == 40 + + +# --- match_cleanup / run_cleanup (session removal) -------------------------------- + + +def test_match_cleanup_matches_by_cwd_glob_and_substitutes(profiles_file, tmp_path): + home = str(tmp_path) + profiles_file( + f""" + [[profile]] + name = "igloo" + cwd = "{home}/igloo-dev" + command = "claude" + match_cwd = "{home}/igloo-dev/projects/*" + cleanup = "./do_worktree.sh remove {{basename}}" + """ + ) + m = profiles.match_cleanup(f"{home}/igloo-dev/projects/my issue", "my issue") + assert m is not None + assert m["profile"] == "igloo" + assert m["run_cwd"] == f"{home}/igloo-dev" + # {basename} is the cwd basename, shell-quoted (has a space). + assert m["command"] == "./do_worktree.sh remove 'my issue'" + + +def test_match_cleanup_none_when_no_glob_or_no_cleanup(profiles_file, tmp_path): + home = str(tmp_path) + profiles_file( + f""" + [[profile]] + name = "no-clean" + cwd = "{home}/x" + match_cwd = "{home}/x/*" + + [[profile]] + name = "has-clean" + cwd = "{home}/y" + match_cwd = "{home}/y/*" + cleanup = "echo bye" + """ + ) + assert profiles.match_cleanup(f"{home}/x/w", "w") is None # matches but no cleanup + assert profiles.match_cleanup("/somewhere/else", "w") is None # no glob match + assert profiles.match_cleanup(f"{home}/y/w", "w")["command"] == "echo bye" + + +def test_run_cleanup_executes_in_run_cwd(tmp_path): + ok, out = profiles.run_cleanup("pwd", str(tmp_path)) + assert ok and out.strip().endswith(str(tmp_path).split("/")[-1]) + bad_ok, _ = profiles.run_cleanup("exit 3", str(tmp_path)) + assert bad_ok is False + + +# --- router endpoints ------------------------------------------------------------- + + +class FakeStore: + def __init__(self): + self.entries = [] + + def log(self, sid, action, detail=""): + self.entries.append((sid, action, detail)) + + +@pytest.fixture +def client(monkeypatch, tmp_path): + app = FastAPI() + app.include_router(tmux_router.router) + app.state.autopilot = type("AP", (), {"store": FakeStore()})() + + calls: list[tuple] = [] + monkeypatch.setattr(tmux_router.tmux, "available", lambda: True) + monkeypatch.setattr(tmux_router, "_default_session", lambda: "main") + monkeypatch.setattr( + tmux_router.tmux, + "new_window", + lambda cwd, command, session=None, name=None: calls.append((cwd, command, session, name)) + or (True, "%9"), + ) + c = TestClient(app) + c.calls = calls + return c + + +def test_list_profiles_includes_builtin(client, profiles_file): + r = client.get("/api/tmux/profiles") + assert r.status_code == 200 + assert [p["name"] for p in r.json()] == ["Claude"] + + +def test_list_profiles_surfaces_broken_config(client, profiles_file): + profiles_file("nope [[") + r = client.get("/api/tmux/profiles") + assert r.status_code == 400 and "profiles" in r.json()["detail"].lower() + + +def test_launch_default_profile_uses_default_session(client, profiles_file): + r = client.post("/api/tmux/profiles/Claude/launch", json={"values": {}}) + assert r.status_code == 200 and r.json() == {"ok": True, "pane_id": "%9"} + home = __import__("os").path.expanduser("~") + # No params → the window is labelled with the profile name. + assert client.calls == [(home, "claude", "main", "Claude")] + + +def test_launch_renders_params_and_targets_group(client, profiles_file, tmp_path): + scratch = tmp_path / "proj" + scratch.mkdir() + profiles_file( + f""" + [[profile]] + name = "p" + cwd = "{scratch}" + command = "echo {{issue}}; bash" + params = [{{ key = "issue", prompt = "Issue" }}] + """ + ) + r = client.post( + "/api/tmux/profiles/p/launch", + json={"values": {"issue": "abc 1"}, "session": "work"}, + ) + assert r.status_code == 200 + # First param value becomes the window label; command param is shell-quoted. + assert client.calls == [(str(scratch), "echo 'abc 1'; bash", "work", "abc 1")] + + +def test_launch_unknown_profile_is_404(client, profiles_file): + r = client.post("/api/tmux/profiles/ghost/launch", json={"values": {}}) + assert r.status_code == 404 and client.calls == [] + + +def test_launch_rejects_missing_cwd(client, profiles_file, tmp_path): + profiles_file( + f'[[profile]]\nname = "p"\ncwd = "{tmp_path / "does-not-exist"}"\ncommand = "claude"' + ) + r = client.post("/api/tmux/profiles/p/launch", json={"values": {}}) + assert r.status_code == 400 and "not a directory" in r.json()["detail"] + assert client.calls == [] + + +def test_launch_rejects_bad_session_name(client, profiles_file): + r = client.post("/api/tmux/profiles/Claude/launch", json={"values": {}, "session": "a:b"}) + assert r.status_code == 400 and client.calls == [] diff --git a/backend/tests/test_reply_queue.py b/backend/tests/test_reply_queue.py new file mode 100644 index 0000000..4be5088 --- /dev/null +++ b/backend/tests/test_reply_queue.py @@ -0,0 +1,258 @@ +"""Queued replies: store FIFO bookkeeping, guarded controller delivery, and the +REST endpoints.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from muse.autopilot import controller as ctl_mod +from muse.autopilot.controller import AutopilotController +from muse.autopilot.store import AutopilotStore +from muse.models import LiveSession +from muse.routers import queue as queue_router + + +@pytest.fixture +def store(tmp_path): + s = AutopilotStore(tmp_path / "test.db") + yield s + s.close() + + +# --- store --------------------------------------------------------------------- + + +def test_queue_fifo_and_counts(store): + a = store.queue_add("s1", "first") + b = store.queue_add("s1", "second") + store.queue_add("s2", "other") + assert store.queue_counts() == {"s1": 2, "s2": 1} + assert store.queue_next("s1").id == a.id + store.queue_mark(a.id, "sent") + assert store.queue_next("s1").id == b.id + assert store.queue_counts()["s1"] == 1 + + +def test_queue_cancel_only_pending(store): + item = store.queue_add("s1", "x") + assert store.queue_cancel("s1", item.id) is True + assert store.queue_cancel("s1", item.id) is False # already cancelled + sent = store.queue_add("s1", "y") + store.queue_mark(sent.id, "sent") + assert store.queue_cancel("s1", sent.id) is False # already delivered + assert store.queue_next("s1") is None + + +def test_queue_for_lists_pending_and_history(store): + a = store.queue_add("s1", "done") + store.queue_mark(a.id, "sent") + b = store.queue_add("s1", "waiting") + rows = store.queue_for("s1") + assert [(r.id, r.status) for r in rows] == [(a.id, "sent"), (b.id, "pending")] + + +def test_queue_batch_and_mode(store): + a = store.queue_add("s1", "first") + b = store.queue_add("s1", "and also this", mode="append") + c = store.queue_add("s1", "separate turn") + # first + its trailing appends batch together; the 'turn' item waits. + assert [i.id for i in store.queue_next_batch("s1")] == [a.id, b.id] + # flip c to append → it joins the batch + assert store.queue_set_mode("s1", c.id, "append") is True + assert [i.id for i in store.queue_next_batch("s1")] == [a.id, b.id, c.id] + # a sent item can't change mode + store.queue_mark(a.id, "sent") + assert store.queue_set_mode("s1", a.id, "append") is False + # after 'a' is gone, the batch restarts at 'b' (append glues to nothing → leads) + assert [i.id for i in store.queue_next_batch("s1")] == [b.id, c.id] + + +# --- controller delivery --------------------------------------------------------- + + +@pytest.fixture +def controller(tmp_path, monkeypatch): + monkeypatch.setattr( + ctl_mod, "get_settings", lambda: type("S", (), {"db_path": tmp_path / "t.db", + "ai_daily_budget_usd": 0.0})() + ) + c = AutopilotController() + c._sent: list[tuple] = [] + monkeypatch.setattr( + ctl_mod.tmux, "send_text", + lambda p, t, submit=True: (c._sent.append((p, t)) or (True, "")), + ) + yield c + c.store.close() + + +def _live(monkeypatch, status="idle", waiting_for=None, pane="%1"): + ls = LiveSession(session_id="s1", pid=1, status=status, + waiting_for=waiting_for, pane_id=pane) + monkeypatch.setattr(ctl_mod.live_discovery, "discover", lambda: [ls]) + + +def test_deliver_when_idle(controller, monkeypatch): + _live(monkeypatch) + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "❯ plain prompt") + item = controller.store.queue_add("s1", "go on") + assert controller.deliver_queued() == [item.id] + assert controller._sent == [("%1", "go on")] + assert controller.store.queue_next("s1") is None + row = controller.store.queue_for("s1")[0] + assert row.status == "sent" and row.sent_at is not None + + +def test_no_delivery_while_busy_or_waiting(controller, monkeypatch): + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "") + controller.store.queue_add("s1", "later") + _live(monkeypatch, status="busy") + assert controller.deliver_queued() == [] + _live(monkeypatch, status="idle", waiting_for="permission") + assert controller.deliver_queued() == [] + _live(monkeypatch, status="idle", pane=None) + assert controller.deliver_queued() == [] + assert controller._sent == [] + + +def test_no_delivery_over_visible_menu(controller, monkeypatch): + _live(monkeypatch) + menu = ( + "Do you want to proceed?\n" + "❯ 1. Yes\n" + " 2. Yes, and don't ask again\n" + " 3. No\n" + ) + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: menu) + controller.store.queue_add("s1", "2 looks fine") # digits would SELECT — must not send + assert controller.deliver_queued() == [] + assert controller._sent == [] + + +def test_no_delivery_into_rate_limit_banner(controller, monkeypatch): + _live(monkeypatch) + monkeypatch.setattr( + ctl_mod.tmux, "capture_pane", + lambda p, n: "5-hour limit reached ∙ resets 3pm", + ) + resets = [] + hits = [] + controller.on_reset = resets.append + controller.on_limit = hits.append + controller.store.queue_add("s1", "keep going") + assert controller.deliver_queued() == [] + assert controller._sent == [] + assert len(resets) == 1 # the observed reset still anchors stats + assert hits == ["5h"] # …and the sighting calibrates the observed ceiling + + +def test_append_items_deliver_as_one_paste(controller, monkeypatch): + _live(monkeypatch) + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "") + pasted = [] + monkeypatch.setattr( + ctl_mod.tmux, "paste_text", + lambda p, t, submit=True: (pasted.append((p, t)) or (True, "")), + ) + a = controller.store.queue_add("s1", "run the tests") + b = controller.store.queue_add("s1", "then update the changelog", mode="append") + assert controller.deliver_queued() == [a.id, b.id] + # multiline combined → bracketed paste, not send-keys (raw LF would submit early) + assert pasted == [("%1", "run the tests\nthen update the changelog")] + assert controller._sent == [] + assert controller.store.queue_next("s1") is None + + +def test_cooldown_blocks_double_fire(controller, monkeypatch): + _live(monkeypatch) + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "") + controller.store.queue_add("s1", "one") + controller.store.queue_add("s1", "two") + assert len(controller.deliver_queued()) == 1 + assert len(controller.deliver_queued()) == 0 # within cooldown + controller._queue_sent_at["s1"] = 0.0 # cooldown elapsed + assert len(controller.deliver_queued()) == 1 + assert [t for _, t in controller._sent] == ["one", "two"] + + +def test_hold_reason_explains_why_not_delivering(controller, monkeypatch): + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "❯ plain") + controller.store.queue_add("s1", "later") + _live(monkeypatch, status="busy") + assert controller.queue_hold_reason("s1") == "session is busy, not idle" + _live(monkeypatch, status="shell") + assert controller.queue_hold_reason("s1") == "session is shell, not idle" + _live(monkeypatch, status="idle", waiting_for="permission") + assert controller.queue_hold_reason("s1") == "waiting for permission" + _live(monkeypatch, status="idle", pane=None) + assert controller.queue_hold_reason("s1") == "session isn’t running in tmux" + _live(monkeypatch, status="idle") + assert controller.queue_hold_reason("s1") is None # would deliver + + +def test_hold_reason_none_when_queue_empty(controller, monkeypatch): + _live(monkeypatch, status="busy") + assert controller.queue_hold_reason("s1") is None + + +def test_deliver_now_overrides_idle_gate(controller, monkeypatch): + _live(monkeypatch, status="shell") # would never auto-deliver + monkeypatch.setattr(ctl_mod.tmux, "capture_pane", lambda p, n: "❯ plain") + item = controller.store.queue_add("s1", "go anyway") + assert controller.deliver_queued() == [] # auto path holds + sent, reason = controller.deliver_now("s1") + assert sent == [item.id] and reason is None + assert controller._sent == [("%1", "go anyway")] + + +def test_deliver_now_still_refuses_a_menu(controller, monkeypatch): + _live(monkeypatch, status="shell") + monkeypatch.setattr( + ctl_mod.tmux, "capture_pane", lambda p, n: "Proceed?\n❯ 1. Yes\n 2. No\n" + ) + controller.store.queue_add("s1", "1") + sent, reason = controller.deliver_now("s1") + assert sent == [] and reason and "menu" in reason + assert controller._sent == [] + + +def test_deliver_now_nothing_queued(controller, monkeypatch): + _live(monkeypatch) + assert controller.deliver_now("s1") == ([], "nothing queued") + + +# --- REST ------------------------------------------------------------------------ + + +@pytest.fixture +def client(store): + app = FastAPI() + app.include_router(queue_router.router) + app.state.autopilot = type( + "AP", + (), + { + "store": store, + "queue_hold_reason": lambda self, sid: None, + "deliver_now": lambda self, sid: ([1], None), + }, + )() + return TestClient(app) + + +def test_queue_endpoints_roundtrip(client): + r = client.post("/api/sessions/s9/queue", json={"text": " after this, run tests "}) + assert r.status_code == 200 + item = r.json() + assert item["text"] == "after this, run tests" and item["status"] == "pending" + view = client.get("/api/sessions/s9/queue").json() + assert [q["id"] for q in view["items"]] == [item["id"]] + assert view["hold_reason"] is None + assert client.delete(f"/api/sessions/s9/queue/{item['id']}").status_code == 200 + assert client.delete(f"/api/sessions/s9/queue/{item['id']}").status_code == 404 + assert client.post("/api/sessions/s9/queue", json={"text": " "}).status_code == 400 + + +def test_send_now_endpoint(client, store): + store.queue_add("s9", "x") + assert client.post("/api/sessions/s9/queue/send-now").json() == {"ok": True, "sent": [1]} diff --git a/backend/tests/test_runway.py b/backend/tests/test_runway.py new file mode 100644 index 0000000..c9b8725 --- /dev/null +++ b/backend/tests/test_runway.py @@ -0,0 +1,125 @@ +"""Runway computation: window anchoring, spend sums, burn projection, and the +honest budget policy (configured > observed ceiling > none — never estimates).""" + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from muse import runway +from muse.usage_cache import Event, Scan + +NOW = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) + + +def _event(sid, minutes_ago, output=100_000): + return Event( + sid=sid, project_dir="-p", ts=NOW - timedelta(minutes=minutes_ago), + input=1000, output=output, cc=0, cr=0, + model="claude-opus-4-8", is_subagent=False, agent_type="", + ) + + +class FakeHistory: + def __init__(self, reset=None, ceilings=None): + self._reset = reset + self._ceilings = ceilings or {} + + def latest_reset(self): + return self._reset + + def observed_ceiling(self, kind="5h", days=30): + return self._ceilings.get(kind) + + +def _settings(limit_5h=None, limit_week=None): + return SimpleNamespace(limit_5h_usd=limit_5h, limit_week_usd=limit_week) + + +@pytest.fixture(autouse=True) +def env(monkeypatch, tmp_path): + events = [ + _event("hot", 10), # in 5h window + burn sample + _event("hot", 20), + _event("warm", 60), # in 5h window only + _event("old", 60 * 26), # weekly only + _event("ancient", 60 * 24 * 8), # outside every window + ] + monkeypatch.setattr(runway, "scan_all", lambda: Scan(events, 5, {"-p": 5})) + monkeypatch.setattr(runway.discovery, "list_sessions", lambda: []) + monkeypatch.setattr(runway, "get_settings", _settings) + monkeypatch.setattr( + runway, "detect_plan", + lambda a, b: type("P", (), {"label": "Claude Team · Max 5×"})(), + ) + runway._cache = (0.0, None) + + +def test_window_sums_and_top_sessions(): + r = runway.compute_runway(history=None, now=NOW) + per_event = r.five_hour.cost_usd / 3 # 3 events in the 5h window, equal cost + assert per_event > 0 + assert r.week.cost_usd == pytest.approx(per_event * 4) + assert [s.session_id for s in r.top_sessions] == ["hot", "warm"] + assert r.five_hour.anchor_source == "estimated" + assert r.plan_label == "Claude Team · Max 5×" + + +def test_subscription_without_calibration_has_no_budget(): + # No env override, no observed limit hit → no made-up ceiling, no projection. + r = runway.compute_runway(history=FakeHistory(), now=NOW) + for w in (r.five_hour, r.week): + assert w.budget_usd is None and w.pct_used is None and w.budget_source == "none" + assert r.projected_exhaust_at is None and r.exhaust_before_reset is False + assert r.burn_usd_per_hour > 0 # burn is always reported + + +def test_observed_ceiling_becomes_budget(): + hist = FakeHistory(ceilings={"5h": 80.0, "week": 500.0}) + r = runway.compute_runway(history=hist, now=NOW) + assert r.five_hour.budget_usd == 80.0 and r.five_hour.budget_source == "observed" + assert r.week.budget_usd == 500.0 and r.week.budget_source == "observed" + assert r.five_hour.pct_used == pytest.approx(r.five_hour.cost_usd / 80.0, abs=1e-3) + assert r.projected_exhaust_at is not None + + +def test_configured_env_beats_observed(monkeypatch): + monkeypatch.setattr(runway, "get_settings", lambda: _settings(limit_5h=42.0)) + r = runway.compute_runway(history=FakeHistory(ceilings={"5h": 80.0}), now=NOW) + assert r.five_hour.budget_usd == 42.0 and r.five_hour.budget_source == "configured" + + +def test_observed_reset_anchors_window(): + # Reset observed 7h ago → current window began 2h ago (reset + 1*5h). + r = runway.compute_runway(history=FakeHistory(reset=NOW - timedelta(hours=7)), now=NOW) + assert r.five_hour.anchor_source == "reset" + assert r.five_hour.anchor == NOW - timedelta(hours=2) + assert r.five_hour.elapsed_seconds == 2 * 3600 + assert r.five_hour.cost_usd > 0 + + +def test_burn_projection_with_budget(monkeypatch): + monkeypatch.setattr(runway, "get_settings", lambda: _settings(limit_5h=150.0)) + r = runway.compute_runway(history=None, now=NOW) + # 2 events in the last 30 min → burn = 2*cost/0.5h + assert r.burn_usd_per_hour == pytest.approx((r.five_hour.cost_usd / 3) * 4, rel=1e-6) + assert r.projected_exhaust_at is not None + # cheap events: exhaustion lands far beyond this window + assert r.exhaust_before_reset is False + + +def test_exhausted_budget_projects_now(monkeypatch): + monkeypatch.setattr(runway, "get_settings", lambda: _settings(limit_5h=0.01)) + r = runway.compute_runway(history=None, now=NOW) + assert r.exhaust_before_reset is True + assert r.projected_exhaust_at == NOW + + +def test_ttl_cache(monkeypatch): + calls = [] + orig = runway.compute_runway + monkeypatch.setattr(runway, "compute_runway", + lambda history=None, now=None: calls.append(1) or orig(history, now)) + runway.get_runway() + runway.get_runway() + assert len(calls) == 1 diff --git a/backend/tests/test_slash_commands.py b/backend/tests/test_slash_commands.py new file mode 100644 index 0000000..15b7adb --- /dev/null +++ b/backend/tests/test_slash_commands.py @@ -0,0 +1,66 @@ +"""Slash-command discovery for the composer '/' menu.""" + +from __future__ import annotations + +from pathlib import Path + +from muse import slash_commands + + +def test_builtins_present_without_cwd(): + cmds = slash_commands.list_commands(None) + names = {c["name"] for c in cmds} + assert "compact" in names + assert "model" in names + assert all(c["source"] == "builtin" for c in cmds if c["name"] == "compact") + + +def test_sorted_by_name(): + cmds = slash_commands.list_commands(None) + names = [c["name"] for c in cmds] + assert names == sorted(names) + + +def test_project_commands_discovered(tmp_path: Path): + cmd_dir = tmp_path / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "deploy.md").write_text( + "---\ndescription: Ship it to prod\n---\nRun the deploy.\n" + ) + cmds = slash_commands.list_commands(str(tmp_path)) + deploy = next(c for c in cmds if c["name"] == "deploy") + assert deploy["source"] == "project" + assert deploy["description"] == "Ship it to prod" + + +def test_description_falls_back_to_first_line(tmp_path: Path): + cmd_dir = tmp_path / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "note.md").write_text("# Take a note\nbody\n") + cmds = slash_commands.list_commands(str(tmp_path)) + note = next(c for c in cmds if c["name"] == "note") + assert note["description"] == "Take a note" + + +def test_subdir_commands_are_namespaced(tmp_path: Path): + cmd_dir = tmp_path / ".claude" / "commands" / "frontend" + cmd_dir.mkdir(parents=True) + (cmd_dir / "build.md").write_text("Build the frontend\n") + cmds = slash_commands.list_commands(str(tmp_path)) + assert any(c["name"] == "frontend:build" for c in cmds) + + +def test_project_overrides_builtin(tmp_path: Path): + cmd_dir = tmp_path / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "review.md").write_text("Custom review flow\n") + cmds = slash_commands.list_commands(str(tmp_path)) + review = [c for c in cmds if c["name"] == "review"] + assert len(review) == 1 + assert review[0]["source"] == "project" + + +def test_missing_cwd_dir_is_harmless(tmp_path: Path): + # cwd exists but has no .claude/commands — just the built-ins. + cmds = slash_commands.list_commands(str(tmp_path)) + assert all(c["source"] != "project" for c in cmds) diff --git a/backend/tests/test_snapshot.py b/backend/tests/test_snapshot.py new file mode 100644 index 0000000..7d94ef4 --- /dev/null +++ b/backend/tests/test_snapshot.py @@ -0,0 +1,211 @@ +"""Session restore: snapshot the tmux topology (tagging each window with its resumable +Claude session id), and rebuild selected groups after a reboot — recreating windows that +run `claude --resume `, skipping anything already live so restores never duplicate.""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from muse.autopilot import snapshot as snap +from muse.models import LiveSession +from muse.routers import tmux as tmux_router + + +def pane(session, widx, pane_id, *, cmd="claude", cwd="/w", name="win", active=True, pidx=0): + return { + "session_name": session, + "window_index": widx, + "window_name": name, + "window_active": active, + "pane_id": pane_id, + "pane_index": pidx, + "pane_active": active, + "command": cmd, + "cwd": cwd, + "title": "", + "session_attached": True, + "last_activity": 0, + "window_id": "@" + pane_id[1:], + } + + +# --- build_snapshot --------------------------------------------------------------- + + +def test_build_tags_windows_with_resumable_session_id(monkeypatch): + panes = [ + pane("work", 0, "%1", cwd="/a", name="api"), + pane("work", 1, "%2", cwd="/b", name="ui"), + pane("exp", 0, "%3", cmd="bash", cwd="/c", name="shell"), + ] + live = [ + LiveSession(session_id="sid-a", pid=10, pane_id="%1", cwd="/a"), + LiveSession(session_id="sid-b", pid=11, pane_id="%2", cwd="/b"), + ] + monkeypatch.setattr(snap.tmux, "available", lambda: True) + monkeypatch.setattr(snap.tmux, "list_layout", lambda: panes) + monkeypatch.setattr(snap.live_discovery, "discover", lambda: live) + + s = snap.build_snapshot() + work = next(g for g in s["groups"] if g["name"] == "work") + assert [w["session_id"] for w in work["windows"]] == ["sid-a", "sid-b"] + assert all(w["kind"] == "claude" for w in work["windows"]) + exp = next(g for g in s["groups"] if g["name"] == "exp") + assert exp["windows"][0]["kind"] == "shell" and exp["windows"][0]["session_id"] is None + + +def test_build_returns_none_without_claude(monkeypatch): + monkeypatch.setattr(snap.tmux, "available", lambda: True) + monkeypatch.setattr(snap.tmux, "list_layout", lambda: [pane("s", 0, "%1", cmd="bash")]) + monkeypatch.setattr(snap.live_discovery, "discover", lambda: []) + assert snap.build_snapshot() is None + + +def test_topology_sig_ignores_ts_and_tracks_structure(): + a = {"ts": "T1", "groups": [{"name": "g", "windows": [ + {"window_name": "w", "cwd": "/x", "session_id": "s", "kind": "claude"}]}]} + b = {**a, "ts": "T2"} # only ts differs + assert snap.topology_sig(a) == snap.topology_sig(b) + c = json.loads(json.dumps(a)) + c["groups"][0]["windows"][0]["cwd"] = "/y" + assert snap.topology_sig(c) != snap.topology_sig(a) + + +# --- annotate_liveness ------------------------------------------------------------ + + +def _snap(*groups): + return {"ts": "T", "groups": list(groups)} + + +def _g(name, *windows): + return {"name": name, "windows": list(windows)} + + +def _w(name, sid, kind="claude", cwd="/w"): + return {"window_name": name, "cwd": cwd, "command": "claude", "kind": kind, "session_id": sid} + + +def test_offer_true_when_claude_windows_none_live(monkeypatch): + monkeypatch.setattr(snap, "_live_index", lambda: (set(), set(), set())) + out = snap.annotate_liveness(_snap(_g("a", _w("x", "s1"), _w("y", "s2")))) + assert out["offer"] is True and out["restorable_count"] == 2 + assert all(not w["live"] for g in out["groups"] for w in g["windows"]) + + +def test_offer_false_when_a_session_is_live(monkeypatch): + monkeypatch.setattr(snap, "_live_index", lambda: ({"s1"}, set(), set())) + out = snap.annotate_liveness(_snap(_g("a", _w("x", "s1"), _w("y", "s2")))) + assert out["offer"] is False # not a clean reboot — something's still running + live = {w["window_name"]: w["live"] for g in out["groups"] for w in g["windows"]} + assert live == {"x": True, "y": False} + + +# --- restore ---------------------------------------------------------------------- + + +@pytest.fixture +def restore_calls(monkeypatch, tmp_path): + """Fresh tmux with nothing live; record new_session/new_window calls.""" + calls = {"session": [], "window": []} + monkeypatch.setattr(snap, "_live_index", lambda: (set(), set(), set())) + monkeypatch.setattr( + snap.tmux, "new_session", + lambda name, cwd=None, command=None, window_name=None: + calls["session"].append((name, cwd, command, window_name)) or (True, ""), + ) + monkeypatch.setattr( + snap.tmux, "new_window", + lambda cwd, command, session=None, name=None: + calls["window"].append((cwd, command, session, name)) or (True, "%9"), + ) + monkeypatch.setattr(snap.os.path, "isdir", lambda p: True) # cwds exist + return calls + + +def test_restore_creates_session_then_windows_resuming_claude(restore_calls): + s = _snap(_g("work", _w("api", "sid-a", cwd="/a"), _w("ui", "sid-b", cwd="/b"))) + res = snap.restore(s, ["work"]) + assert res["restored"] == 2 and res["groups"] == ["work"] + # First missing window creates the session (no leftover placeholder shell)... + assert restore_calls["session"] == [("work", "/a", "claude --resume sid-a || claude --continue", "api")] + # ...the rest append as windows. + assert restore_calls["window"] == [("/b", "claude --resume sid-b || claude --continue", "work", "ui")] + + +def test_restore_skips_live_windows(monkeypatch, restore_calls): + monkeypatch.setattr(snap, "_live_index", lambda: ({"sid-a"}, set(), {"work"})) + s = _snap(_g("work", _w("api", "sid-a"), _w("ui", "sid-b"))) + res = snap.restore(s, ["work"]) + assert res["restored"] == 1 and res["skipped"] == 1 + # 'work' already exists (live), so the surviving window appends — no new session. + assert restore_calls["session"] == [] + assert restore_calls["window"] == [("/w", "claude --resume sid-b || claude --continue", "work", "ui")] + + +def test_restore_shell_and_unknown_id_and_missing_cwd(monkeypatch, restore_calls): + monkeypatch.setattr(snap.os.path, "isdir", lambda p: p == "/ok") + s = _snap(_g( + "g", + _w("editor", None, kind="shell", cwd="/gone"), # shell + missing cwd → ~ + bare shell + _w("chat", None, kind="claude", cwd="/ok"), # claude, no id → --continue + )) + snap.restore(s, ["g"]) + home = snap.os.path.expanduser("~") + assert restore_calls["session"][0] == ("g", home, None, "editor") # command None → default shell + assert restore_calls["window"][0] == ("/ok", "claude --continue", "g", "chat") + + +# --- endpoints -------------------------------------------------------------------- + + +class FakeStore: + def __init__(self, snapshot=None): + self._latest = (snap.topology_sig(snapshot), json.dumps(snapshot)) if snapshot else None + self.entries = [] + + def latest_snapshot(self): + return self._latest + + def log(self, sid, action, detail=""): + self.entries.append((sid, action, detail)) + + +def _client(store, monkeypatch): + app = FastAPI() + app.include_router(tmux_router.router) + app.state.autopilot = type("AP", (), {"store": store})() + monkeypatch.setattr(tmux_router.tmux, "available", lambda: True) + return TestClient(app) + + +def test_get_snapshot_endpoint_annotates(monkeypatch): + monkeypatch.setattr(tmux_router.layout_snapshot, "_live_index", lambda: (set(), set(), set())) + store = FakeStore(_snap(_g("a", _w("x", "s1"), _w("y", "s2")))) + r = _client(store, monkeypatch).get("/api/tmux/snapshot") + assert r.status_code == 200 and r.json()["offer"] is True + + empty = _client(FakeStore(None), monkeypatch).get("/api/tmux/snapshot") + assert empty.json() == {"ts": None, "groups": [], "offer": False, "restorable_count": 0} + + +def test_restore_endpoint_rebuilds_selected(monkeypatch): + monkeypatch.setattr(tmux_router.layout_snapshot, "_live_index", lambda: (set(), set(), set())) + monkeypatch.setattr(tmux_router.layout_snapshot.os.path, "isdir", lambda p: True) + made = [] + monkeypatch.setattr( + tmux_router.layout_snapshot.tmux, "new_session", + lambda name, cwd=None, command=None, window_name=None: made.append(name) or (True, ""), + ) + store = FakeStore(_snap(_g("a", _w("x", "s1")), _g("b", _w("y", "s2")))) + r = _client(store, monkeypatch).post("/api/tmux/restore", json={"groups": ["a"]}) + assert r.status_code == 200 and r.json()["restored"] == 1 + assert made == ["a"] # only the selected group + assert ("tmux", "layout_restore", "1 windows in ['a']") in store.entries + + +def test_restore_endpoint_400_without_snapshot(monkeypatch): + r = _client(FakeStore(None), monkeypatch).post("/api/tmux/restore", json={"groups": ["a"]}) + assert r.status_code == 400 diff --git a/backend/tests/test_tmux_groups.py b/backend/tests/test_tmux_groups.py new file mode 100644 index 0000000..8b36add --- /dev/null +++ b/backend/tests/test_tmux_groups.py @@ -0,0 +1,209 @@ +"""Groups = tmux sessions: move whole windows between sessions, create/rename/kill +sessions. tmux.move_window computes an append index from the live layout; the router +validates window ids and group names before shelling out.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from muse.autopilot import tmux as tmux_mod +from muse.routers import tmux as tmux_router + + +# --- tmux.move_window (pure-ish; list_layout + _run are the only I/O) ------------- + + +def _layout(): + return [ + {"session_name": "a", "window_index": 0, "window_id": "@5"}, + {"session_name": "b", "window_index": 0, "window_id": "@1"}, + {"session_name": "b", "window_index": 3, "window_id": "@2"}, + ] + + +def test_move_window_appends_after_last_index(monkeypatch): + calls = [] + monkeypatch.setattr(tmux_mod, "list_layout", _layout) + monkeypatch.setattr(tmux_mod, "_run", lambda args, **kw: calls.append(args) or (0, "", "")) + ok, err = tmux_mod.move_window("@5", "b") + assert ok and err == "" + assert calls == [["move-window", "-s", "@5", "-t", "b:4"]] # max(0,3)+1 + + +def test_move_window_noop_when_already_in_dst(monkeypatch): + calls = [] + monkeypatch.setattr(tmux_mod, "list_layout", _layout) + monkeypatch.setattr(tmux_mod, "_run", lambda args, **kw: calls.append(args) or (0, "", "")) + ok, err = tmux_mod.move_window("@1", "b") # @1 already lives in b + assert ok and err == "" and calls == [] + + +def test_move_window_unknown_targets(monkeypatch): + monkeypatch.setattr(tmux_mod, "list_layout", _layout) + monkeypatch.setattr(tmux_mod, "_run", lambda args, **kw: (0, "", "")) + assert tmux_mod.move_window("@99", "b")[0] is False # window not found + assert tmux_mod.move_window("@5", "ghost")[0] is False # session not found + + +def test_rename_window_disables_auto_rename_then_renames(monkeypatch): + calls = [] + monkeypatch.setattr(tmux_mod, "_run", lambda args, **kw: calls.append(args) or (0, "", "")) + ok, err = tmux_mod.rename_window("@5", "api-refactor") + assert ok and err == "" + assert calls == [ + ["set-window-option", "-t", "@5", "automatic-rename", "off"], + ["rename-window", "-t", "@5", "api-refactor"], + ] + + +# --- router endpoints ------------------------------------------------------------- + + +class FakeStore: + def __init__(self): + self.entries = [] + + def log(self, sid, action, detail=""): + self.entries.append((sid, action, detail)) + + +@pytest.fixture +def client(monkeypatch): + app = FastAPI() + app.include_router(tmux_router.router) + app.state.autopilot = type("AP", (), {"store": FakeStore()})() + + calls: list[tuple] = [] + monkeypatch.setattr(tmux_router.tmux, "available", lambda: True) + monkeypatch.setattr( + tmux_router.tmux, "new_session", lambda n: (calls.append(("new", n)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, "rename_session", lambda o, n: (calls.append(("rename", o, n)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, "kill_session", lambda n: (calls.append(("kill", n)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, "move_window", lambda w, s: (calls.append(("move", w, s)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, "rename_window", lambda w, n: (calls.append(("winrename", w, n)) or (True, "")) + ) + monkeypatch.setattr( + tmux_router.tmux, "kill_window", lambda w: (calls.append(("killwin", w)) or (True, "")) + ) + # A window @7 whose active pane sits in a cwd claimed by a cleanup profile. + monkeypatch.setattr( + tmux_router.tmux, + "list_layout", + lambda: [ + {"window_id": "@7", "pane_active": True, "cwd": "/proj/stuff", "window_name": "stuff"} + ], + ) + c = TestClient(app) + c.calls = calls + return c + + +def test_close_window_kills_only_without_cleanup(client, monkeypatch): + monkeypatch.setattr( + tmux_router.profiles_mod, "match_cleanup", lambda cwd, name: {"command": "x", "run_cwd": "/"} + ) + ran = [] + monkeypatch.setattr( + tmux_router.profiles_mod, "run_cleanup", lambda c, d: ran.append(c) or (True, "") + ) + r = client.post("/api/tmux/windows/@7/close", json={"cleanup": False}) + assert r.status_code == 200 and r.json()["cleanup_ran"] is False + assert ("killwin", "@7") in client.calls and ran == [] # no cleanup when not opted in + + +def test_close_window_runs_cleanup_after_kill(client, monkeypatch): + monkeypatch.setattr( + tmux_router.profiles_mod, + "match_cleanup", + lambda cwd, name: {"command": "./do_worktree.sh remove stuff", "run_cwd": "/proj"}, + ) + ran = [] + monkeypatch.setattr( + tmux_router.profiles_mod, "run_cleanup", lambda c, d: ran.append((c, d)) or (True, "removed") + ) + r = client.post("/api/tmux/windows/@7/close", json={"cleanup": True}) + body = r.json() + assert body["cleanup_ran"] and body["cleanup_ok"] and body["cleanup_output"] == "removed" + assert ("killwin", "@7") in client.calls + assert ran == [("./do_worktree.sh remove stuff", "/proj")] + + +def test_close_window_rejects_bad_window_id(client): + r = client.post("/api/tmux/windows/nope/close", json={"cleanup": False}) + assert r.status_code == 400 and client.calls == [] + + +def test_window_cleanup_preview(client, monkeypatch): + monkeypatch.setattr( + tmux_router.profiles_mod, + "match_cleanup", + lambda cwd, name: {"profile": "igloo", "command": "rm stuff", "run_cwd": "/proj"}, + ) + r = client.get("/api/tmux/windows/@7/cleanup") + assert r.status_code == 200 + assert r.json() == { + "window_name": "stuff", + "cwd": "/proj/stuff", + "cleanup": {"profile": "igloo", "command": "rm stuff"}, + } + + +def test_create_group_ok(client): + r = client.post("/api/tmux/sessions", json={"name": "work"}) + assert r.status_code == 200 and r.json()["session"] == "work" + assert ("new", "work") in client.calls + + +@pytest.mark.parametrize("bad", ["a.b", "a:b", "with space", "", "x" * 41]) +def test_create_group_rejects_bad_names(client, bad): + r = client.post("/api/tmux/sessions", json={"name": bad}) + assert r.status_code == 400 + assert client.calls == [] # never reached tmux + + +def test_rename_group(client): + r = client.post("/api/tmux/sessions/work/rename", json={"name": "play"}) + assert r.status_code == 200 and r.json()["session"] == "play" + assert ("rename", "work", "play") in client.calls + + +def test_delete_group(client): + r = client.request("DELETE", "/api/tmux/sessions/work") + assert r.status_code == 200 + assert ("kill", "work") in client.calls + + +def test_move_window_endpoint(client): + r = client.post("/api/tmux/windows/@7/move", json={"session": "work"}) + assert r.status_code == 200 and r.json() == {"ok": True, "window_id": "@7", "session": "work"} + assert ("move", "@7", "work") in client.calls + + +def test_move_window_rejects_bad_window_id(client): + r = client.post("/api/tmux/windows/nope/move", json={"session": "work"}) + assert r.status_code == 400 and client.calls == [] + + +def test_rename_window_endpoint(client): + r = client.post("/api/tmux/windows/@7/rename", json={"name": " api-refactor "}) + assert r.status_code == 200 and r.json()["name"] == "api-refactor" # trimmed + assert ("winrename", "@7", "api-refactor") in client.calls + + +@pytest.mark.parametrize("bad", ["", " ", "a\nb", "x" * 101]) +def test_rename_window_rejects_bad_names(client, bad): + r = client.post("/api/tmux/windows/@7/rename", json={"name": bad}) + assert r.status_code == 400 and client.calls == [] + + +def test_rename_window_rejects_bad_window_id(client): + r = client.post("/api/tmux/windows/nope/rename", json={"name": "x"}) + assert r.status_code == 400 and client.calls == [] diff --git a/frontend/docs/profiles.example.toml b/frontend/docs/profiles.example.toml new file mode 100644 index 0000000..705fa56 --- /dev/null +++ b/frontend/docs/profiles.example.toml @@ -0,0 +1,21 @@ +# muse launch profiles — copy to ~/.muse/profiles.toml (or set MUSE_PROFILES_PATH). +# +# Each [[profile]] is a template for "New window" on the /panes page: +# name — shown in the launcher menu (must be unique; avoid "/") +# cwd — working directory the window starts in (default: "~") +# command — shell string run in the new window (default: "claude") +# params — optional inputs muse prompts for and substitutes as {key} +# +# A built-in "Claude" profile (cwd ~, command claude) always exists; define your own +# "Claude" here to override it. Command params are shell-quoted; cwd params are literal. + +[[profile]] +name = "igloo dev" +cwd = "~/workspace/igloo-dev" +command = "./dev.sh {issue} && claude" +params = [{ key = "issue", prompt = "Issue name", default = "" }] + +[[profile]] +name = "web" +cwd = "~/workspace/web" +command = "npm run dev & claude" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d92b58e..a2c6ee7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,13 +18,75 @@ "shiki": "^1.22.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", + "jsdom": "^29.1.1", "typescript": "^5.6.2", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^4.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -306,6 +368,193 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -695,6 +944,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -712,6 +979,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -729,6 +1014,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -797,6 +1100,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -843,6 +1164,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -852,31 +1202,27 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -884,27 +1230,33 @@ "license": "MIT", "optional": true, "os": [ - "android" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -912,11 +1264,266 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ @@ -1278,6 +1885,108 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1323,6 +2032,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1332,6 +2052,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1436,9 +2163,140 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", "dependencies": { @@ -1474,6 +2332,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1548,6 +2416,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1627,12 +2505,47 @@ "node": ">=10" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1650,6 +2563,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1672,6 +2592,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1694,6 +2624,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.368", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", @@ -1707,6 +2645,19 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1725,6 +2676,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1796,12 +2754,50 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -1955,6 +2951,19 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -1991,6 +3000,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2074,12 +3093,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2111,57 +3188,339 @@ "node": ">=6" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "detect-libc": "^2.0.3" }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lowlight": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", - "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.0.0", - "highlight.js": "~11.11.0" + "engines": { + "node": ">= 12.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/markdown-table": { @@ -2456,6 +3815,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -3025,6 +4391,16 @@ ], "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3069,6 +4445,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oniguruma-to-es": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", @@ -3135,6 +4525,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3150,16 +4553,36 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -3185,6 +4608,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3206,6 +4653,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3326,6 +4783,20 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regex": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", @@ -3434,11 +4905,21 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", @@ -3464,6 +4945,47 @@ "node": ">=4" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -3509,6 +5031,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3544,6 +5079,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3573,6 +5115,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3587,6 +5143,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3623,6 +5192,103 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3643,6 +5309,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3657,6 +5331,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -3877,6 +5561,759 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a386fb..e50c34f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^18.3.1", @@ -19,10 +20,14 @@ "shiki": "^1.22.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", + "jsdom": "^29.1.1", "typescript": "^5.6.2", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^4.1.9" } -} +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 304e5d1..c0ed1d0 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,7 +5,7 @@ * opportunistically (cache-first) and let new hashes populate naturally; the SPA * shell ("/") is network-first so a deploy is picked up, with an offline fallback. */ -const CACHE = "muse-shell-v1"; +const CACHE = "muse-shell-v2"; const SHELL = ["/", "/manifest.webmanifest"]; self.addEventListener("install", (event) => { @@ -32,8 +32,11 @@ self.addEventListener("fetch", (event) => { // Never intercept API / SSE / auth — let the browser handle them (with cookies). if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/mcp")) return; - // Navigations: network-first, fall back to the cached shell when offline. - if (request.mode === "navigate") { + // The shell ("/") is ALWAYS network-first — navigations AND programmatic + // fetches (the new-build check) — so a cached copy never shadows the live + // index.html. Falls back to the cached shell when offline. Never writes to + // cache here, so cache-busted checks don't pollute it. + if (request.mode === "navigate" || url.pathname === "/") { event.respondWith( fetch(request).catch(() => caches.match("/", { ignoreSearch: true })), ); @@ -81,11 +84,15 @@ self.addEventListener("notificationclick", (event) => { const target = (event.notification.data && event.notification.data.url) || "/"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => { + const dest = new URL(target, self.location.origin); for (const w of wins) { - if ("focus" in w) { - w.navigate(target); - return w.focus(); - } + if (!("focus" in w)) continue; + // Already on the target page (e.g. mid-reply on that very session) — just + // focus it. Navigating would reload the page and wipe whatever's typed. + const here = new URL(w.url); + if (here.pathname + here.search === dest.pathname + dest.search) return w.focus(); + if ("navigate" in w) return w.navigate(target).then((c) => (c || w).focus()); + return w.focus(); } return self.clients.openWindow(target); }), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0652a78..f27a1af 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -24,11 +24,19 @@ import type { NotifyConfig, OpenLoop, NotifyResult, + PaneScreen, + SlashCommand, PendingOptions, PushSubscriptionInfo, + QueuedReply, + QueueView, + RunwayResponse, + HooksStatus, TmuxLayout, Pack, + LayoutSnapshot, PersistedOutput, + Profile, ReentryBrief, RelatedSession, SearchResponse, @@ -48,11 +56,23 @@ function notifyAuthRequired(status: number): void { if (status === 401) window.dispatchEvent(new Event("muse:auth-required")); } +// FastAPI reports errors as `{ "detail": "..." }`; prefer that human message over the +// bare status line so config/validation errors (e.g. a broken profiles.toml) surface. +async function errorMessage(res: Response, url: string): Promise { + try { + const body = (await res.clone().json()) as { detail?: unknown }; + if (typeof body?.detail === "string" && body.detail) return body.detail; + } catch { + /* not JSON — fall through to the status line */ + } + return `${res.status} ${res.statusText} for ${url}`; +} + async function getJSON(url: string, signal?: AbortSignal): Promise { const res = await fetch(url, { signal }); if (!res.ok) { notifyAuthRequired(res.status); - throw new Error(`${res.status} ${res.statusText} for ${url}`); + throw new Error(await errorMessage(res, url)); } return res.json() as Promise; } @@ -65,7 +85,7 @@ async function sendJSON(method: string, url: string, body?: unknown): Promise }); if (!res.ok) { notifyAuthRequired(res.status); - throw new Error(`${res.status} ${res.statusText} for ${url}`); + throw new Error(await errorMessage(res, url)); } return res.json() as Promise; } @@ -307,6 +327,32 @@ export const api = { sendSessionKey: (sessionId: string, key: "escape" | "enter" | "accept") => sendJSON<{ ok: boolean }>("POST", `/api/sessions/${sessionId}/keys`, { key }), + // --- queued replies (deliver when the turn actually ends) --- + getQueue: (sessionId: string, signal?: AbortSignal) => + getJSON(`/api/sessions/${sessionId}/queue`, signal), + + queueReply: (sessionId: string, text: string, mode: "turn" | "append" = "turn") => + sendJSON("POST", `/api/sessions/${sessionId}/queue`, { text, mode }), + + // User override: deliver the next queued batch now (skips wait-for-idle). + sendQueuedNow: (sessionId: string) => + sendJSON<{ ok: boolean; sent: number[] }>( + "POST", + `/api/sessions/${sessionId}/queue/send-now`, + {}, + ), + + setQueuedReplyMode: (sessionId: string, qid: number, mode: "turn" | "append") => + sendJSON<{ ok: boolean }>("POST", `/api/sessions/${sessionId}/queue/${qid}/mode`, { mode }), + + cancelQueuedReply: (sessionId: string, qid: number) => + sendJSON<{ ok: boolean }>("DELETE", `/api/sessions/${sessionId}/queue/${qid}`), + + // --- budget runway + hook telemetry --- + getRunway: (signal?: AbortSignal) => getJSON("/api/runway", signal), + + getHooksStatus: () => getJSON("/api/hooks/status"), + getSessionSends: (sessionId: string, limit = 20) => getJSON<{ ts: string; action: string; detail: string }[]>( `/api/sessions/${sessionId}/sends?limit=${limit}`, @@ -318,11 +364,26 @@ export const api = { ), // --- tmux topology (mobile panes view) --- - getTmuxLayout: (signal?: AbortSignal) => - getJSON("/api/tmux/layout", signal), + // previews=false omits per-pane screen text (~250KB across a fleet) — the + // task list polls that shape; the deck uses getPaneScreen for live terminals. + getTmuxLayout: (previews = true, signal?: AbortSignal) => + getJSON(`/api/tmux/layout?previews=${previews ? 1 : 0}`, signal), + + getPaneScreen: (paneId: string, signal?: AbortSignal) => + getJSON( + `/api/tmux/panes/${encodeURIComponent(paneId)}/screen`, + signal, + ), // Pane ids look like "%12" — the leading % is URL-encoding's escape char, so it // MUST be encoded (→ "%2512") or the server decodes it into a different/invalid id. + // Slash commands available to a pane's session, for the composer's "/" menu. + getPaneCommands: (paneId: string, signal?: AbortSignal) => + getJSON( + `/api/tmux/panes/${encodeURIComponent(paneId)}/commands`, + signal, + ), + sendToPane: (paneId: string, text: string, submit = true) => sendJSON<{ ok: boolean }>( "POST", @@ -348,6 +409,71 @@ export const api = { newPaneSession: (cwd?: string) => sendJSON<{ ok: boolean; pane_id: string }>("POST", "/api/tmux/new", { cwd: cwd ?? null }), + // --- launch profiles (~/.muse/profiles.toml) --- + listProfiles: () => getJSON("/api/tmux/profiles"), + + launchProfile: (name: string, values: Record, session?: string | null) => + sendJSON<{ ok: boolean; pane_id: string }>( + "POST", + `/api/tmux/profiles/${encodeURIComponent(name)}/launch`, + { values, session: session ?? null }, + ), + + // --- session restore (rebuild the tmux layout after a reboot) --- + getTmuxSnapshot: () => getJSON("/api/tmux/snapshot"), + + restoreTmuxLayout: (groups: string[]) => + sendJSON<{ ok: boolean; restored: number; skipped: number; groups: string[] }>( + "POST", + "/api/tmux/restore", + { groups }, + ), + + // --- groups (tmux sessions) --- + // Organize panes by moving whole windows between tmux sessions. Window ids look + // like "@12" — @ is encoding-safe, but encode anyway to match the pane-id pattern. + createTmuxSession: (name: string) => + sendJSON<{ ok: boolean; session: string }>("POST", "/api/tmux/sessions", { name }), + + renameTmuxSession: (name: string, next: string) => + sendJSON<{ ok: boolean; session: string }>( + "POST", + `/api/tmux/sessions/${encodeURIComponent(name)}/rename`, + { name: next }, + ), + + deleteTmuxSession: (name: string) => + sendJSON<{ ok: boolean }>("DELETE", `/api/tmux/sessions/${encodeURIComponent(name)}`), + + moveTmuxWindow: (windowId: string, session: string) => + sendJSON<{ ok: boolean; window_id: string; session: string }>( + "POST", + `/api/tmux/windows/${encodeURIComponent(windowId)}/move`, + { session }, + ), + + renameTmuxWindow: (windowId: string, name: string) => + sendJSON<{ ok: boolean; window_id: string; name: string }>( + "POST", + `/api/tmux/windows/${encodeURIComponent(windowId)}/rename`, + { name }, + ), + + // Removing a session: preview the profile cleanup (if any), then close the window. + getWindowCleanup: (windowId: string) => + getJSON<{ + window_name: string; + cwd: string; + cleanup: { profile: string; command: string } | null; + }>(`/api/tmux/windows/${encodeURIComponent(windowId)}/cleanup`), + + closeTmuxWindow: (windowId: string, cleanup: boolean) => + sendJSON<{ ok: boolean; cleanup_ran: boolean; cleanup_ok: boolean; cleanup_output: string }>( + "POST", + `/api/tmux/windows/${encodeURIComponent(windowId)}/close`, + { cleanup }, + ), + // --- web push notifications --- getVapidKey: () => getJSON<{ public_key: string }>("/api/notify/vapid-key"), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4e51e2d..09a3d5a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -774,6 +774,7 @@ export interface TmuxPane { pane_id: string; session_name: string; window_index: number; + window_id: string; // stable window handle (@) — the target for move-window window_name: string; window_active: boolean; pane_index: number; @@ -782,12 +783,23 @@ export interface TmuxPane { cwd: string; title: string; session_attached: boolean; + last_activity: number; muse_session_id: string | null; context_pct: number | null; + queued: number; status: "needs_you" | "responded" | "working" | "idle"; attention: string; mode: "default" | "acceptEdits" | "plan" | "bypass" | null; - preview: string; + preview: string; // full visible screen (ANSI); empty when polled with previews=0 + preview_tail: string; // last visible line — task-list subtitle + options: PendingOption[]; +} + +/** One pane's live visible screen (from /api/tmux/panes/{id}/screen). */ +export interface PaneScreen { + ok: boolean; + text: string; + mode: "default" | "acceptEdits" | "plan" | "bypass" | null; options: PendingOption[]; } @@ -797,6 +809,114 @@ export interface TmuxLayout { reason: string | null; } +export interface SlashCommand { + name: string; // invoked as "/{name}"; subdir commands are namespaced with ":" + description: string; + source: "builtin" | "user" | "project"; +} + +/** A declared input a launch profile prompts for before running (substituted as {key}). */ +export interface ProfileParam { + key: string; + prompt: string; + default: string; +} + +/** A launch profile from ~/.muse/profiles.toml: a template for opening a new window. */ +export interface Profile { + name: string; + cwd: string; + command: string; + params: ProfileParam[]; + builtin: boolean; // the always-present "Claude" default +} + +/** One window in a saved layout snapshot (for session-restore after a reboot). */ +export interface SnapshotWindow { + window_name: string; + cwd: string; + command: string; + kind: "claude" | "shell"; + session_id: string | null; // resumable Claude id, when captured + live: boolean; // already running in the current tmux (skipped on restore) +} + +export interface SnapshotGroup { + name: string; + windows: SnapshotWindow[]; +} + +/** The latest tmux topology snapshot, annotated for the restore UI. */ +export interface LayoutSnapshot { + ts: string | null; // null when nothing captured yet + groups: SnapshotGroup[]; + offer: boolean; // post-reboot signal: has Claude windows, none currently live + restorable_count: number; +} + +/** A message queued for delivery when its session's turn ends. */ +export interface QueuedReply { + id: number; + session_id: string; + text: string; + created_at: string | null; + status: "pending" | "sent" | "cancelled" | "failed"; + /** turn = deliver alone (own turn); append = glue onto the previous item. */ + mode: "turn" | "append"; + sent_at: string | null; + error: string | null; +} + +export interface QueueView { + items: QueuedReply[]; + /** Why the pending queue isn't delivering right now (null = would deliver). */ + hold_reason: string | null; +} + +export interface RunwaySession { + session_id: string; + title: string; + cost_usd: number; +} + +export interface RunwayWindow { + label: string; + window_seconds: number; + anchor: string | null; + anchor_source: string; // "reset" when anchored to an observed usage-limit reset + elapsed_seconds: number; + remaining_seconds: number; + cost_usd: number; + budget_usd: number | null; + budget_source: "configured" | "observed" | "none"; + pct_used: number | null; + pct_elapsed: number; +} + +/** Budget headroom before the plan's 5h/weekly limits bite. */ +export interface RunwayResponse { + generated_at: string; + plan_label: string | null; + five_hour: RunwayWindow; + week: RunwayWindow; + burn_usd_per_hour: number; + projected_exhaust_at: string | null; + exhaust_before_reset: boolean; + top_sessions: RunwaySession[]; +} + +/** Claude Code hook relay install/ingest state. */ +export interface HooksStatus { + script_exists: boolean; + endpoint_file_exists: boolean; + installed_events: string[]; + expected_events: string[]; + events_seen: number; + by_event: Record; + last_event_at: string | null; + recent: { ts: string; event: string; session_id: string; message: string }[]; +} + export interface PendingOptions { session_id: string; source: "permission" | "tool_question" | "none"; diff --git a/frontend/src/components/ConversationView.clamp.test.tsx b/frontend/src/components/ConversationView.clamp.test.tsx new file mode 100644 index 0000000..b08a30d --- /dev/null +++ b/frontend/src/components/ConversationView.clamp.test.tsx @@ -0,0 +1,87 @@ +/** Focus (phone reader) mode clamps a verbose tool result to FOCUS_RESULT_LINES + * — but never more than the tool's own format limit — with a tap to expand. */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ConversationView from "./ConversationView"; +import type { ContentBlock, ThreadItem } from "../api/types"; + +vi.mock("./Markdown", () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})); + +const noop = () => {}; + +// An assistant item with one Bash call whose result is 6 lines of output. +function bashItem(uuid: string, lines: number): ThreadItem { + const content = Array.from({ length: lines }, (_, i) => `line-${i}`).join("\n"); + const block: ContentBlock = { + kind: "tool_use" as ContentBlock["kind"], + text: null, + tool_use: { + id: `${uuid}-t`, + name: "Bash", + input: { command: "ls" }, + caller: null, + result: { content, is_error: false } as unknown as NonNullable< + NonNullable["result"] + >, + subagent: null, + }, + }; + return { + uuid, + parent_uuid: null, + role: "assistant", + type: "assistant", + timestamp: null, + blocks: [block], + text: "", + usage: null, + model: null, + is_sidechain: false, + level: null, + }; +} + +function renderView(focus: boolean) { + return render( + , + ); +} + +describe("focus-mode tool-result clamp", () => { + it("shows at most 3 result lines collapsed, with a +N indicator", () => { + renderView(true); + expect(screen.getByText("line-0")).toBeTruthy(); + expect(screen.getByText("line-2")).toBeTruthy(); + expect(screen.queryByText("line-3")).toBeNull(); + expect(screen.getByText(/\+3 lines/)).toBeTruthy(); + }); + + it("collapsed rows are single-line truncated (clamped class); expand clears it", () => { + renderView(true); + expect(document.querySelector(".cc-result-clamped")).toBeTruthy(); + fireEvent.click(screen.getByText("line-0").closest(".cc-tool-line")!); + expect(document.querySelector(".cc-result-clamped")).toBeNull(); + }); + + it("tapping the tool line expands to the full output and un-clamps the header", () => { + renderView(true); + expect(document.querySelector(".cc-tool-head.open")).toBeNull(); + fireEvent.click(screen.getByText("line-0").closest(".cc-tool-line")!); + expect(screen.getByText("line-5")).toBeTruthy(); + expect(document.querySelector(".cc-tool-head.open")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ConversationView.groups.test.tsx b/frontend/src/components/ConversationView.groups.test.tsx new file mode 100644 index 0000000..ffb08b3 --- /dev/null +++ b/frontend/src/components/ConversationView.groups.test.tsx @@ -0,0 +1,112 @@ +/** Focus-mode tool-run grouping: a stretch of tool-only items must fold into a + * single tappable row (with the live current step still visible), expand in + * place, survive live appends without collapsing, and leave the non-focus + * render completely untouched. */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ConversationView from "./ConversationView"; +import { + makeHiddenUserItem, + makeItem, + makeToolOnlyItem, +} from "../util/readerFixtures"; +import type { ThreadItem } from "../api/types"; + +vi.mock("./Markdown", () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})); + +const noop = () => {}; + +function renderView(items: ThreadItem[], focus = true) { + return render( + , + ); +} + +// text — 4 completed tool calls (with carriers) — text +function conversation(): ThreadItem[] { + const items: ThreadItem[] = [makeItem("intro")]; + for (let i = 0; i < 4; i++) { + items.push(makeToolOnlyItem(`t${i}`, 1, 1, { name: "Bash" })); + items.push(makeHiddenUserItem(`t${i}-r`)); + } + items.push(makeItem("outro")); + return items; +} + +describe("ConversationView tool-run grouping (focus mode)", () => { + it("collapses a run to one row and hides the member tool lines", () => { + renderView(conversation()); + expect(screen.getByText(/4 steps/)).toBeTruthy(); + expect(screen.getByText(/Bash ×4/)).toBeTruthy(); + // The individual tool lines are folded away… + expect(screen.queryAllByText("Bash")).toHaveLength(0); + // …while the surrounding conversation still renders. + expect(screen.getByText("text of intro")).toBeTruthy(); + expect(screen.getByText("text of outro")).toBeTruthy(); + }); + + it("tap expands the run in place; the header folds it again", () => { + renderView(conversation()); + fireEvent.click(screen.getByText(/4 steps/)); + expect(screen.getAllByText("Bash")).toHaveLength(4); + expect(screen.getByText(/tap to fold/)).toBeTruthy(); + fireEvent.click(screen.getByText(/4 steps/)); + expect(screen.queryAllByText("Bash")).toHaveLength(0); + }); + + it("stays open across a live append (key is the stable first member)", () => { + const items = conversation().slice(0, -1); // drop outro: run is the tail + const { rerender } = renderView(items); + fireEvent.click(screen.getByText(/4 steps/)); + expect(screen.getAllByText("Bash")).toHaveLength(4); + rerender( + , + ); + // Still open — now with the appended call rendered too. + expect(screen.getAllByText("Bash")).toHaveLength(5); + }); + + it("a collapsed run at the live tail shows its current step", () => { + const items = conversation().slice(0, -1); + items.push(makeToolOnlyItem("live", 1, 0, { name: "Grep" })); // running + renderView(items); + expect(screen.getByText(/5 steps/)).toBeTruthy(); + // The newest call is visible with its running connector… + expect(screen.getByText("Grep")).toBeTruthy(); + expect(screen.getByText("running…")).toBeTruthy(); + // …but the finished members stay folded. + expect(screen.queryAllByText("Bash")).toHaveLength(0); + }); + + it("non-focus render is unchanged: no group rows, every tool line present", () => { + renderView(conversation(), false); + expect(screen.queryByText(/4 steps/)).toBeNull(); + expect(screen.getAllByText("Bash")).toHaveLength(4); + }); +}); diff --git a/frontend/src/components/ConversationView.reader.test.tsx b/frontend/src/components/ConversationView.reader.test.tsx new file mode 100644 index 0000000..335662c --- /dev/null +++ b/frontend/src/components/ConversationView.reader.test.tsx @@ -0,0 +1,50 @@ +/** Reader-mode rendering contract: non-virtualized ConversationView must render + * the ENTIRE window it's given — first and last item — so the panes reader can + * scroll to the newest message. Guards against internal render-windowing (the + * virtualize path) silently applying and eating the tail. */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ConversationView from "./ConversationView"; +import { makeItems } from "../util/readerFixtures"; + +// Markdown pulls react-markdown + highlight.js; a plain passthrough keeps this +// test about ConversationView's own windowing, not markdown rendering. +vi.mock("./Markdown", () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})); + +const noop = () => {}; + +function renderView(count: number) { + return render( + , + ); +} + +describe("ConversationView in reader mode (compact+focus, non-virtualized)", () => { + it("renders both the first and the LAST item of a tail window", () => { + renderView(40); + expect(screen.getByText("text of m0")).toBeTruthy(); + expect(screen.getByText("text of m39")).toBeTruthy(); + }); + + it("renders every item of an accumulated multi-window thread", () => { + renderView(150); + // 150 > any window/threshold constant in play — nothing may be dropped. + expect(screen.getByText("text of m0")).toBeTruthy(); + expect(screen.getByText("text of m75")).toBeTruthy(); + expect(screen.getByText("text of m149")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ConversationView.tsx b/frontend/src/components/ConversationView.tsx index ad094a1..a0fe67f 100644 --- a/frontend/src/components/ConversationView.tsx +++ b/frontend/src/components/ConversationView.tsx @@ -12,6 +12,7 @@ import type { ThreadItem, ToolUse } from "../api/types"; import { summarize } from "./renderers"; import { classifyUser, toolArg, toolBody, toolTitle } from "./ccInline"; import { toolStatus } from "../util/toolIndex"; +import { computeToolRuns, type ToolRun } from "../util/toolRuns"; import Markdown from "./Markdown"; import BookmarkControl from "./BookmarkControl"; import WelcomeBanner from "./WelcomeBanner"; @@ -87,6 +88,9 @@ const OVERSCAN = 1500; // Height assumed for a not-yet-measured row (only affects the scrollbar estimate). const EST_HEIGHT = 72; +// Shared empty map so non-focus renders never allocate (or regroup). +const EMPTY_RUNS = new Map(); + /** All searchable text for an item (assistant/user text, tool args + results). */ function itemText(item: ThreadItem): string { const parts: string[] = []; @@ -428,6 +432,24 @@ function ConversationView( const matchSet = new Set(matches); const currentUuid = matches.length ? matches[Math.min(current, matches.length - 1)] : null; + // Focus mode: fold runs of consecutive tool-only items into one row each. + // Memo keyed on items identity — the reader hook only swaps items on real + // updates, so this doesn't recompute on no-op polls. Open state is keyed by + // the run's first-member uuid, which appends never change. + const runs = useMemo( + () => (focus ? computeToolRuns(items) : EMPTY_RUNS), + [focus, items], + ); + const [openRuns, setOpenRuns] = useState>(() => new Set()); + const toggleRun = useCallback((key: string) => { + setOpenRuns((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + const renderItem = (item: ThreadItem, i: number) => (
renderItem(item, i)); + if (runs.size > 0) { + // Grouped render (focus only): a run's first member renders the fold row; + // the members render only while the run is open. A collapsed run at the + // LIVE TAIL still shows its newest call, so a working session's current + // step stays visible. Search matches force the run open so results + // aren't invisible. Item keys/refs are untouched — renderItem as-is. + const lastUuid = items[items.length - 1]?.uuid; + const nodes: React.ReactNode[] = []; + items.forEach((item, i) => { + const run = runs.get(item.uuid); + if (!run) { + nodes.push(renderItem(item, i)); + return; + } + const open = + openRuns.has(run.key) || (q !== "" && [...run.memberUuids].some((u) => matchSet.has(u))); + if (item.uuid === run.firstUuid) { + nodes.push( + toggleRun(run.key)} + />, + ); + if (!open && lastUuid !== undefined && run.memberUuids.has(lastUuid) && run.lastTool) { + nodes.push( +
+ onSelectTool(run.lastTool!.id, "conversation")} + registerRef={registerToolRef} + focus={focus} + /> +
, + ); + } + } + if (open) nodes.push(renderItem(item, i)); + }); + body = nodes; + } else { + body = items.map((item, i) => renderItem(item, i)); + } } else { rebuildCum(); // authoritative offsets from the latest measured heights const c = cum.current; @@ -481,7 +547,7 @@ function ConversationView( } return ( -
+
{!compact && (
void; +}) { + const status = run.errors > 0 ? "error" : run.running ? "pending" : "ok"; + const shown = run.counts.slice(0, RUN_COUNTS_SHOWN); + const elided = run.counts.length - shown.length; + return ( +
{ + if (e.key === "Enter" || e.key === " ") onToggle(); + }} + > + {open ? "▾" : "⚒"} + + {run.calls} steps{run.running && !open ? "…" : ""} + + + {shown.map((c) => (c.n > 1 ? `${c.name} ×${c.n}` : c.name)).join(" ") + + (elided > 0 ? ` +${elided} more` : "")} + + {run.errors > 0 && ( + + {run.errors} error{run.errors === 1 ? "" : "s"} + + )} + {open && tap to fold} +
+ ); +} + function ToolLine({ tool, selected, @@ -682,7 +796,7 @@ function ToolLine({ onSelect(); }} > -
+
{toolTitle(tool.name)} {arg && ({arg})} @@ -713,13 +827,16 @@ function ResultConnector({ ); } const { rows, limit } = toolBody(tool); - // Focus mode (phone): collapse the whole result to a single tap-to-expand line - // unless the user opened it — keeps the conversation readable on a small screen. - const effectiveLimit = focus && !expanded ? 0 : limit; + // Focus mode (phone): show at most a few lines of the result unless expanded, so + // a long tool output never dominates the small screen. The cap is format-aware — + // it never exceeds the tool's own preview limit (Read is 1 line, Bash up to 8…), + // so terse results stay terse and only verbose ones get clamped to FOCUS_RESULT_LINES. + const clamped = focus && !expanded; + const effectiveLimit = clamped ? Math.min(limit, FOCUS_RESULT_LINES) : limit; const shown = expanded ? rows : rows.slice(0, effectiveLimit); const hidden = rows.length - shown.length; return ( -
+
{shown.map((r, i) => ( diff --git a/frontend/src/components/OptionPicker.tsx b/frontend/src/components/OptionPicker.tsx index fd39cea..4bf33bd 100644 --- a/frontend/src/components/OptionPicker.tsx +++ b/frontend/src/components/OptionPicker.tsx @@ -45,6 +45,9 @@ export default function OptionPicker({ className="option-freetext-input" autoFocus placeholder="Type your reply…" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} value={freeText} onChange={(e) => setFreeText(e.target.value)} /> diff --git a/frontend/src/components/RunwayBar.tsx b/frontend/src/components/RunwayBar.tsx new file mode 100644 index 0000000..d319f49 --- /dev/null +++ b/frontend/src/components/RunwayBar.tsx @@ -0,0 +1,88 @@ +import { useCallback, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import type { RunwayResponse, RunwayWindow } from "../api/types"; +import { usePolling } from "../hooks/usePolling"; + +function fmtDur(secs: number): string { + const h = Math.floor(secs / 3600); + const m = Math.round((secs % 3600) / 60); + return h > 0 ? `${h}h${String(m).padStart(2, "0")}` : `${m}m`; +} + +function level(w: RunwayWindow, exhaust: boolean): "ok" | "warn" | "over" { + if (w.pct_used != null && w.pct_used >= 1) return "over"; + // Ahead of pace or projected to hit the wall before the reset → warn. + if (exhaust || (w.pct_used != null && w.pct_used > w.pct_elapsed + 0.15)) return "warn"; + return "ok"; +} + +const SOURCE_HINT: Record = { + observed: "ceiling calibrated from your last observed limit hit", + configured: "ceiling from MUSE_LIMIT_*_USD", + none: "no $ cap on a subscription plan — muse learns the ceiling when a limit banner appears", +}; + +function Seg({ w, exhaust, name }: { w: RunwayWindow; exhaust: boolean; name: string }) { + const title = `${w.label} — ${SOURCE_HINT[w.budget_source] ?? ""}`; + // Subscription plans have no published $ caps: without a configured/observed + // ceiling there is no bar to fill — just report spend honestly. + if (w.budget_usd == null) { + return ( +
+ {name} + ${w.cost_usd.toFixed(0)} + ↻{fmtDur(w.remaining_seconds)} +
+ ); + } + const lvl = level(w, exhaust); + const pct = Math.min(1, w.pct_used ?? 0); + return ( +
+ {name} + + + {/* time-progress notch: spend left of the notch = under pace */} + + + + ${w.cost_usd.toFixed(0)}/{w.budget_source === "observed" ? "~" : ""}$ + {w.budget_usd.toFixed(0)} + + ↻{fmtDur(w.remaining_seconds)} +
+ ); +} + +/** One-line budget cockpit: spend vs the plan's 5h + weekly windows, with a + * pace notch and a projected time-to-limit warning. Polls every 30s (the server + * side is TTL-cached over the mtime-cached usage scan). */ +export default function RunwayBar() { + const [runway, setRunway] = useState(null); + const refresh = useCallback(async () => setRunway(await api.getRunway()), []); + usePolling(refresh, 30000); + + if (!runway) return null; + const exhaust = runway.exhaust_before_reset; + const eta = + exhaust && runway.projected_exhaust_at + ? Math.max(0, (new Date(runway.projected_exhaust_at).getTime() - Date.now()) / 1000) + : null; + + return ( + + + + {runway.burn_usd_per_hour > 0 && ( + + {exhaust && eta != null + ? eta <= 0 + ? "⚠ limit hit" + : `⚠ limit in ~${fmtDur(eta)}` + : `$${runway.burn_usd_per_hour.toFixed(0)}/h`} + + )} + + ); +} diff --git a/frontend/src/components/SlashMenu.test.tsx b/frontend/src/components/SlashMenu.test.tsx new file mode 100644 index 0000000..2821683 --- /dev/null +++ b/frontend/src/components/SlashMenu.test.tsx @@ -0,0 +1,97 @@ +/** The "/" autocomplete hook: opens on a leading slash-token, filters, and + * replaces the composer text on pick. Commands load lazily and only once. */ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSlashMenu } from "./SlashMenu"; + +vi.mock("../api/client", () => ({ api: { getPaneCommands: vi.fn() } })); +import { api } from "../api/client"; + +const getPaneCommands = vi.mocked(api.getPaneCommands); +afterEach(() => getPaneCommands.mockReset()); + +const CMDS = [ + { name: "compact", description: "compact it", source: "builtin" as const }, + { name: "clear", description: "clear it", source: "builtin" as const }, + { name: "deploy", description: "ship", source: "project" as const }, +]; + +function Harness({ paneId = "%1", enabled = true }: { paneId?: string; enabled?: boolean }) { + const [text, setText] = useState(""); + const slash = useSlashMenu({ paneId, text, setText, enabled }); + return ( +
+