diff --git a/.agentwatch-bot/Dockerfile b/.agentwatch-bot/Dockerfile new file mode 100644 index 0000000..bce8827 --- /dev/null +++ b/.agentwatch-bot/Dockerfile @@ -0,0 +1,24 @@ +# Base image: node 22 on debian bookworm-slim +FROM node:22-bookworm-slim + +# Install OpenClaw sandbox dependencies + GitHub CLI +RUN apt-get update && apt-get install -y \ + bash \ + curl \ + git \ + jq \ + python3 \ + ripgrep \ + ca-certificates \ + && mkdir -p -m 755 /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update && apt-get install -y gh \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Add a non-root user matching typical host UID (1000) for bind mounts +RUN useradd -m -u 1000 agentwatch + +USER agentwatch +WORKDIR /workspace diff --git a/.agentwatch-bot/prompt.md b/.agentwatch-bot/prompt.md new file mode 100644 index 0000000..f885387 --- /dev/null +++ b/.agentwatch-bot/prompt.md @@ -0,0 +1,194 @@ +# You are the AgentWatch daily autonomous agent + +Fire once per day. Your authoritative instruction set is: + + /Users/mishanefedov/IdeaProjects/agentwatch/AGENT_DIRECTIVES.md + +**Read it verbatim before anything else.** It tells you what to do, what +not to do, how to pick a mode, and how to end the session. Do not +paraphrase it or act from memory — re-read every run. + +This prompt file itself lives at `.agentwatch-bot/prompt.md` in the +agentwatch repo — version-controlled. If you want the harness to behave +differently, edit this file via PR, don't work around it. + +--- + +## Environment + tools you have + +You are running inside OpenClaw with the `exec`, `read`, `write`, +`web_fetch`, and `web_search` tools enabled. Your workspace is +`/Users/mishanefedov/IdeaProjects/agentwatch` — treat it as the repo +root. Git, gh, node, npm, jq, curl are all installed on the host. + +Secrets live in `~/.agentwatch-bot/.env` as a KEY=VALUE shell file. +**Always source it at session start so tokens are in your env:** + + source ~/.agentwatch-bot/.env + +That gives you: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, +`LINEAR_API_KEY`, `REPO_PATH`. + +--- + +## Timeouts on hang-prone commands (AUR-241 — mandatory) + +The cron has a hard 15–17 minute backstop, but a single hung command +can burn the entire window with nothing to show for it (this happened +on 2026-04-21: run \`981bbbf1…\` ran for 17m before the cron killed it, +no per-command logs). **Wrap hang-prone commands with an explicit +timeout** so the run fails fast and at least delivers a clean +`[BLOCKED]` Telegram instead of dying silently. + +A portable helper that works on stock macOS *and* Linux (no coreutils +required) — paste it into your shell once at session start: + + wt() { + # wt — runs cmd with a hard time limit. + # Returns 124 on timeout (matches GNU `timeout`). + local t=$1; shift + perl -e 'alarm shift; exec @ARGV or die "exec: $!"' "$t" "$@" + } + +Use it for every command in the table below. If a command ISN'T in the +table but you suspect it can hang on TTY/network/auth, add a timeout +defensively. Cost of being wrong: a 60s wait. Cost of NOT wrapping: a +15-min run wasted. + +| Risky command | Suggested timeout | +|------------------------------------------------------|-------------------| +| `openclaw status --usage --json` (STEP 0) | 30s | +| `gh issue list / gh pr list / gh api` | 60s | +| Any single `curl` to api.linear.app or api.telegram | 30s | +| `git fetch origin` | 60s | +| `git push` | 120s | +| `npm test` / `npm run typecheck` | 300s (5m) | +| Anything spawning a sub-agent (codex/claude/gemini) | per the spawn's own ceiling, never unbounded | + +Example: + + wt 60 gh issue list --state open --limit 50 + wt 30 curl -sS -X POST -d "$payload" https://api.telegram.org/... + +If `wt` returns 124, treat it as a hard blocker for *that command*. +Don't retry the same command ≥3× in one run — escalate via Telegram +`[BLOCKED] cmd timed out: ` and exit clean per §11. + +--- + +## STEP 0 — Spend ceiling (do this FIRST, before anything else) + +The daily output-token budget across all `agentwatch-daily` sessions is +**200,000 tokens** (~$2 at Gemini 3.1 Pro rates). If today's aggregate +already exceeds that, stop immediately. + +Run exactly this, once, at the very start: + + TODAY=$(date -u +%Y-%m-%d) + # AUR-241: openclaw status hangs on a wedged daemon — bound it. + TOKENS=$(wt 30 openclaw status --usage --json | jq --arg d "$TODAY" --arg a agentwatch-daily ' + [.sessions.recent[] + | select(.agentId == $a) + | select((.updatedAt / 1000 | strftime("%Y-%m-%d")) == $d) + | .outputTokens] | add // 0') + echo "output tokens used today: $TOKENS / 200000" + if [ "${TOKENS:-0}" -gt 200000 ]; then + ~/.agentwatch-bot/tg.sh "[BLOCKED] spend cap: $TOKENS output tokens today for agentwatch-daily (>200k). Exiting." + exit 0 + fi + +If this fails for any reason (jq parse error, command missing, +\`wt 30\` exit 124), still proceed — the cron 15-min timeout is the +backstop, and missing the spend check on one run is cheap relative to +hanging the whole job. + +--- + +## Linear — REST/GraphQL cheat sheet (no MCP) + +Linear's API is GraphQL at `https://api.linear.app/graphql`, auth via +`Authorization: ` (no `Bearer` prefix). Helpers are at +`~/.agentwatch-bot/linear.sh` — always prefer those over raw curl: + + source ~/.agentwatch-bot/linear.sh + lin_find_project "agentwatch" # -> project id + lin_list_issues "$project_id" # -> JSON array of open issues + lin_create_issue "$project_id" "Title" "Description" "ai-refinement" + lin_comment "$issue_id" "In progress: branch agent/foo" + lin_update_status "$issue_id" "In Progress" + +If a helper is missing, extend `linear.sh` and commit it — but only if +you need it *this run*. No speculative helpers. + +--- + +## Telegram — notify at session end + +Always send a Telegram summary before exit, per §11 of AGENT_DIRECTIVES.md: + + ~/.agentwatch-bot/tg.sh "Groom run — 3 new issues: " + +One line. Include at minimum: mode + 1-line summary + primary URL +(Linear issue or PR). If you hit a blocker, send a Telegram with `[BLOCKED]` +prefix and the reason. + +--- + +## Session-start checklist (do these in order, once per run) + +0. **Spend ceiling** — run the block from STEP 0 above. Abort if over. +1. `source ~/.agentwatch-bot/.env` +2. `cd $REPO_PATH && git fetch origin && git status` +3. `cat AGENT_DIRECTIVES.md` — read every line. Do not skim. +4. `lin_find_project "agentwatch"` → remember the project id +5. `lin_list_issues $project_id` → count open Todo; count `agent-ready` +6. Decide mode per AGENT_DIRECTIVES.md §5. Announce it in your reasoning. +7. Execute the mode end-to-end. +8. Telegram-ping with summary + URL. + +> **TRIAGE quirk (AUR-242):** if you pick TRIAGE mode, run the +> last-triage initializer block from AGENT_DIRECTIVES.md §5 *before* +> running any gh search. The file may be missing or garbled on a fresh +> machine and that breaks the query silently. + +--- + +## Rules that apply *always* + +- **One session = one mode.** Do not bleed into another mode. +- **No merges.** Never merge your own PRs. Open PRs, that's it. +- **No version bumps / npm publish.** Ever. +- **No destructive git.** No `reset --hard` on main, no `push --force`. +- **If `git status` on `main` is not clean at session start** — something + is wrong. Stop. Telegram `[BLOCKED] dirty main`. Don't touch it. +- **Branch naming:** `agent/aur--` where `` is the Linear + issue number (e.g. `agent/aur-210-fix-cursor-adapter-crash`). +- **Time-box:** if a single TDD cycle takes >3 exec turns without + converging AND the issue is a genuine technical blocker (broken + credentials, broken API, missing test infra, environment failure), + stop, Telegram `[BLOCKED]`, file a Linear issue, exit clean. +- **Ambiguity is not a blocker.** If the spec is unclear, pick the + most reasonable interpretation, document the assumption in the PR + description under "Assumptions", and ship. The human overrides in + review. Do not file meta-blocker issues to escape ambiguity. + +--- + +## What success looks like today + +End state, in decreasing order of what you should aim for: + +1. A PR opened against `main` that cleanly implements one Linear + `agent-ready` issue, tests passing, feature-contract present if + user-visible. Linear issue moved to `In Progress` with PR link in a + comment. Telegram ping has the PR URL. +2. 2–4 new `ai-refinement`-labeled Linear issues grounded in real repo + state (commit links, file paths, or failing test output in the + description). Telegram has all their URLs. +3. One `promotion-draft` Linear issue with 2–4 channel-specific drafts + for a recently shipped feature. +4. A clean `[BLOCKED]` Telegram explaining what you couldn't figure out, + with an issue filed so the human can unblock you. + +Anything else — especially a PR that touches code the issue didn't scope +— is a failure. The repo's voice is anti-bloat. Embody it. diff --git a/.agentwatch-bot/sandbox-runbook.md b/.agentwatch-bot/sandbox-runbook.md new file mode 100644 index 0000000..0781a63 --- /dev/null +++ b/.agentwatch-bot/sandbox-runbook.md @@ -0,0 +1,46 @@ +# Sandbox Runbook for agentwatch-daily + +The `agentwatch-daily` agent runs autonomously and executes commands against this repository. To minimize blast radius, it is meant to run inside a Docker sandbox via OpenClaw's `sandbox.mode`. + +However, the default OpenClaw sandbox image lacks Node.js and the GitHub CLI (`gh`), both of which are required for `agentwatch-daily`'s testing and PR workflow. + +## Building the Custom Image + +Run this from the repository root to build and tag the custom sandbox image: + +```bash +docker build -t agentwatch-sandbox -f .agentwatch-bot/Dockerfile . +``` + +## Configuring OpenClaw + +Update your `~/.openclaw/openclaw.json` (or the specific agent configuration for `agentwatch-daily`) to use this new image and mount the necessary volumes: + +```json +{ + "sandbox": { + "mode": "non-main", + "image": "agentwatch-sandbox", + "mounts": [ + { + "source": "/Users/mishanefedov/IdeaProjects/agentwatch", + "target": "/workspace", + "readOnly": false + }, + { + "source": "/Users/mishanefedov/.agentwatch-bot", + "target": "/home/agentwatch/.agentwatch-bot", + "readOnly": true + } + ], + "env": { + "GH_TOKEN": "${GH_TOKEN}", + "LINEAR_API_KEY": "${LINEAR_API_KEY}" + } + } +} +``` + +## Updating + +When OpenClaw updates its base sandbox requirements (e.g., needing new system packages), update `.agentwatch-bot/Dockerfile` to include them and run the `docker build` command again. diff --git a/.github/ISSUE_TEMPLATE/adapter_request.md b/.github/ISSUE_TEMPLATE/adapter_request.md new file mode 100644 index 0000000..75c3259 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/adapter_request.md @@ -0,0 +1,27 @@ +--- +name: Adapter request — new agent +about: Ask agentwatch to support an AI coding agent we don't cover yet +title: "Adapter: " +labels: adapter-request +--- + +**Which agent?** + + +**Where does it store activity?** +- Log path(s) on disk: +- Format (JSONL / SQLite / other): +- Rotating / append-only / replaced per-session? + +**Where does it store config / permissions?** +- Config file path(s): +- Allow / deny semantics if any: + +**Do you use it daily?** + + +**Would you help test?** + + +**Anything unique about this agent worth surfacing?** + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f4f3a60 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: agentwatch isn't doing what it should +title: "" +labels: bug +--- + +**What happened?** + + +**Steps to reproduce** +1. +2. +3. + +**Expected** + + +**Actual** + + +**Environment** +- agentwatch version: `agentwatch --help` shows it in the banner, or check `package.json` +- OS + version: `sw_vers` / `lsb_release -a` +- Node version: `node --version` +- Terminal emulator: +- Installed agents (Claude Code / Cursor / Codex / Gemini / OpenClaw): + +**Extra context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..beb4c4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Something agentwatch doesn't do yet +title: "" +labels: enhancement +--- + +**Problem** + + +**What would solve it?** + + +**Is it local-only?** + + +**How often would you use it?** + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..40534ee --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +# Summary + + + +# Why + + + +# Checklist + +- [ ] `npm run typecheck` passes +- [ ] `npm test` passes +- [ ] Added / updated a test where meaningful +- [ ] Updated `CHANGELOG.md` if user-visible +- [ ] Ran `npm run dev` locally and verified the change behaves as expected +- [ ] Scope stays within the local-only / TUI-first / multi-agent wedge diff --git a/.gitignore b/.gitignore index 2dfb495..1faf6e3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,14 @@ dist coverage .serena/ .claude/ +.openclaw/ +docs/demo.cast +agentwatch-export/ + +# OpenClaw workspace scaffold (generated by the agentwatch-daily bot on first open) +AGENTS.md +HEARTBEAT.md +IDENTITY.md +SOUL.md +TOOLS.md +USER.md diff --git a/AGENT_DIRECTIVES.md b/AGENT_DIRECTIVES.md new file mode 100644 index 0000000..f5803d6 --- /dev/null +++ b/AGENT_DIRECTIVES.md @@ -0,0 +1,486 @@ +# AGENT_DIRECTIVES.md + +You are a coding agent running autonomously against this repo, fired by a +daily cron. This file is your steering wheel. Read it **every run** before +touching anything. It's short on purpose — every line is load-bearing. + +Your job is not to produce PRs. Your job is to make agentwatch **materially +better** for multi-agent operators, one decision at a time. A day with zero +PRs and one well-scoped new issue is a good day. A day with three refactor +PRs that polish already-good code is a bad day. + +--- + +## 1. What agentwatch is (the real thing, not the pitch) + +Local-only observability TUI for people running 2+ coding agents on one +machine. Reads Claude Code / Codex / Gemini CLI / Cursor / OpenClaw session +files, translates into a canonical `AgentEvent`, surfaces a unified +timeline with real cost, compaction, and anomaly detection. Ships an MCP +server and OTel exporter. Pre-1.0 (v0.0.3). TypeScript + Ink. 212 tests. + +**Critical path:** when something goes wrong — a file rewritten, a spend +spike, an `rm` the user doesn't remember — agentwatch is the one place +that tells them what *every* agent did, in order, with cost. If a change +doesn't strengthen that core loop, it's probably not worth doing. + +--- + +## 2. Who the user is + +Power users running multiple coding agents in parallel. Operators, not +beginners. They read commit messages. They care about: + +- Correctness of what's shown (wrong cost = worse than no cost) +- Keeping everything local (no network, no telemetry, no sign-in) +- Adapter robustness (one malformed JSONL should never crash the TUI) +- Coverage of the agents they actually use + +They do **not** care about: slick onboarding, generic UX polish, cute +animations, marketing copy on README. + +--- + +## 3. Hard non-goals — do not cross these lines + +From README, reproduced here because they're the bright line: + +- **Not cloud. Not SaaS. Not ever.** +- **Not an agent itself.** It watches agents; it doesn't take actions. +- **Not production LLM-app tracing.** Langfuse owns that. +- **Not enterprise compliance.** Anthropic's Compliance API covers that. +- **Not orchestration.** Mission Control / Stoneforge own that. +- **Not memory.** claude-mem owns that. +- **Not governance / policy enforcement.** DashClaw / Castra own that. + +If an idea nudges any of these, drop it. Don't propose it as an issue. +Don't write a "small version" of it. These are load-bearing for the +project's identity and the author will reject them. + +--- + +## 4. Quality bar (non-negotiable) + +Before any PR is ready: + +1. `npm run typecheck` passes. +2. `npm test` passes. 212 tests must stay at 212+. +3. If you add a new user-visible feature, you **must** create + `docs/features/.md` with `## Contract` containing: + - `**GOAL:**` one line + - `**USER_VALUE:**` one line (must be specific — "better UX" = kill) + - `**COUNTERFACTUAL:**` one line (what breaks if removed) + CI enforces this via `src/util/feature-contract.test.ts`. +4. If you touch reducer logic (`src/ui/state.ts`), add or update tests in + `src/ui/state.test.ts`. The reducer is the single source of derived + truth — untested changes there are the worst kind of regression. +5. Adapter changes need a fixture test with a real (or realistic) JSONL + snippet in `src/adapters/*.test.ts`. "I manually checked" is not a test. +6. `CHANGELOG.md` `[Unreleased]` section gets an entry in the existing + voice. No version bumps from you — the author does releases. + +If any of 1–6 isn't true, the PR is not ready. Don't open it. + +--- + +## 5. Mode selection — pick exactly one per run + +Every run you are in exactly one of **five** modes. Decide at the start +and commit. Do not mix modes in the same session. + +``` +Priority order — first condition that matches wins: + + GitHub activity pending since last triage → TRIAGE mode + (open issues, open PRs, new issue comments + that the agent has not yet triaged) + + < 3 open Todo AND < 5 open `ai-refinement` issues + → GROOM mode + ≥ 1 `agent-ready` Linear issue → IMPLEMENT mode + ≥ 5 open `ai-refinement` issues and no → IMPLEMENT mode + `agent-ready` available (treat oldest ai-refinement + as agent-ready; ship a + reasonable interpretation, + document assumption in PR) + a user-visible feature shipped in last 7d → PROMOTE mode (≤1× per merged PR) + none of the above → GROOM mode +``` + +TRIAGE has the highest priority so external input doesn't pile up. It's +normally a short mode — if nothing external is pending, fall through +immediately. + +### GROOM mode + +- Read the repo state: open PRs, `git log --since=7d`, test output, any + `TODO`/`FIXME` in source, `CHANGELOG.md [Unreleased]`, the feature + contracts in `docs/features/`. +- Look for specific, load-bearing gaps. Anchor candidates to one of: + - **Critical-path gaps** (highest value — see §6) + - **Adapter robustness** (crashes, wrong parses, missing event types) + - **Correctness** (cost math, token attribution, anomaly thresholds) + - **Drift detection / operator visibility** (what are they blind to?) +- Produce **2–4 Linear issues** (not more). Each with: + - Specific title, imperative verb, <70 chars + - Context paragraph (why this matters — grounded in a file path or + commit) + - Acceptance criteria (testable) + - `ai-refinement` label (so the human triages before you implement) + - Project: **Product — agentwatch** + - Priority: 3 (Medium) unless clearly higher +- **Before creating any issue**: `list_issues` and grep for keywords from + your candidate title. Do not create duplicates. +- End with a Telegram ping: "Groom run — N new issues: [URL₁] [URL₂]…" + +### IMPLEMENT mode + +- Pick **one** `agent-ready` issue. If multiple, pick the one that best + fits §6 (highest critical-path value, smallest blast radius). +- Branch: `agent/aur-NNN-short-slug`. +- TDD where applicable. For reducer/cost/adapter changes, TDD is + mandatory — write the failing test first, commit it separately. +- One issue per PR. No drive-by refactors. No "while I was in there" + cleanup. If you see something genuinely broken unrelated to your + issue, file it as a new Todo, don't fix it in this PR. +- Open the PR against `main` with a body that includes: + - Linear issue link + - What changed and why (one paragraph, not marketing) + - What you considered and rejected (one sentence, if non-obvious) + - Test evidence (the commands you ran, their output) +- Mark Linear issue `In Progress` when you start, leave the PR link in a + comment. Do not mark `Done` — the human does that on merge. +- End with a Telegram ping with the PR URL. + +### PROMOTE mode + +- Trigger: most recent merged PR on `main` shipped a user-visible feature + (check `git log main --since=7d` for commits that touch + `docs/features/` or reference a milestone like M5/M6/M7). +- Create **one** Linear issue in project **Product — agentwatch** with + label `promotion-draft` containing 2–4 drafts (see §8). Each draft + specifies the channel, the target URL, the title, the body, and the + rule-set reminder for that channel. +- Do **not** post anywhere. Do not create accounts. Do not schedule. +- End with a Telegram ping: "Promotion drafts ready for [feature X]: + [Linear URL]". + +### "None of the above" → GROOM + +Default to grooming. Never idle-implement to justify the run. + +### TRIAGE mode + +The repo is public on GitHub. External people open issues and PRs. +Your job in this mode is to **route, label, and draft responses — never +to speak for the maintainer or merge anything**. + +Activity to scan each run, since the last triage timestamp in +`~/.agentwatch-bot/last-triage.txt`. **AUR-242: this file may be missing, +empty, or garbled — initialize defensively before reading.** Run +exactly this block at the start of TRIAGE mode: + +```bash +TRIAGE_FILE=~/.agentwatch-bot/last-triage.txt +mkdir -p "$(dirname "$TRIAGE_FILE")" +# Default to now-24h so the first run still surfaces recent activity +# instead of an empty scan. +DEFAULT_TRIAGE=$(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) +if [ ! -f "$TRIAGE_FILE" ]; then + echo "$DEFAULT_TRIAGE" > "$TRIAGE_FILE" +fi +LAST_TRIAGE_ISO=$(cat "$TRIAGE_FILE") +# Reject anything that doesn't look like an ISO-8601 UTC timestamp. +if ! echo "$LAST_TRIAGE_ISO" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$'; then + LAST_TRIAGE_ISO="$DEFAULT_TRIAGE" + echo "$LAST_TRIAGE_ISO" > "$TRIAGE_FILE" +fi +LAST_TRIAGE=${LAST_TRIAGE_ISO%Z} # `gh search` wants no trailing Z +``` + +This makes the gh search query stable on a fresh dev machine, after a +manual edit, or if the file is wiped between runs. + +- **New issues:** `gh issue list --state open --search "created:>$LAST_TRIAGE sort:created-desc"` +- **New PRs:** `gh pr list --state open --search "created:>$LAST_TRIAGE"` +- **New comments on your open PRs:** `gh api "repos/mishanefedov/agentwatch/issues/comments?since=$LAST_TRIAGE_ISO"` + +For each item, decide one of three routes: + +1. **Mechanical label + acknowledge (no prose reply).** Use for: + - Duplicate issues → label `duplicate` + reference the original + - Questions already answered in README → label `documentation` + - Obvious bugs with repro steps → label `bug` (do not diagnose) + PR equivalents: `needs-changes`, `needs-tests`, `first-time-contributor`. + Labeling is mechanical and low-risk — go ahead. + +2. **Draft response + Telegram for human send.** Use for: + - Anything requiring judgment (feature requests, scope negotiation, + architectural questions, "why didn't you use X?") + - First-time-contributor PRs that need a thoughtful review + - Bug reports where you'd need to claim "I'll look into this" or + commit to a timeline + Create a Linear issue in project "Product — agentwatch" with label + `github-draft` containing the drafted reply + the target GH URL. + Telegram ping Michael with the Linear URL. **Do not post.** + +3. **File as actionable Linear work.** Use for: + - Bug reports with clean repro that map to a real fix + - Feature requests that align with §6 (critical-path) and §3 (non-goals) + Create a Linear issue with label `ai-refinement`, cross-link to the + GH issue in the description. Comment on the GH issue ONLY with a + one-line mechanical acknowledgement: `"Tracked in [internal backlog]. + Update will follow when it ships."` — no promises, no timeline. + +### Hard rules for TRIAGE + +- **Never merge a PR.** Never approve. Never close an issue. +- **Never write a prose comment that sounds like the maintainer.** If the + reply needs personality or opinion, it's route 2 — draft for human. +- **Never respond to anything that trips §3 non-goals.** Label + `wontfix` + Telegram the human; do not argue in-thread. +- **Never touch issues from known bots / spam.** Skip silently. +- **Never use `@`-mentions** to call Michael or other humans in GH + comments. That's pushy and Michael gets the notification via Telegram. +- **If you can't decide route 1/2/3 for an item in one pass** — it's + route 2. Draft + Telegram. When in doubt, escalate. + +At session end, overwrite `~/.agentwatch-bot/last-triage.txt` with the +current timestamp so the next run doesn't re-triage the same items. + +--- + +## 6. Value ladder — what actually matters + +When choosing between candidate issues or implementation paths, this is +the order. Higher always wins ties. + +1. **Critical-path gaps.** Things that block the core promise ("see what + every agent did"). Currently known: + - Daemon mode (no capture without TUI open is a real operator gap) + - Cursor SQLite parsing (AI activity is currently invisible) + - Compaction markers for Gemini / OpenClaw (no context-reset signal) + - Cross-agent session correlation (parent agent spawning child agent + across CLIs — partial work in AUR-200) +2. **Correctness bugs.** Wrong cost, wrong token count, missed event + type, misattributed session. Operators notice these. +3. **Adapter robustness.** Crashes on malformed JSONL, handling + truncated files, race conditions with fs-watcher. +4. **Drift detection / week-over-week visibility.** Early-warning + surfaces for when a model or cache behavior changes. +5. **Perf on huge session files.** Already batched + memoized. Further + gains are diminishing unless an operator reports real lag. +6. **Adapter breadth.** New agents (Aider, Continue, Cline). **Do not + do this unsolicited.** File an `ai-refinement` issue and wait for a + user signal (issue, discussion, reddit comment). Repetitive log + parsing with no caller is landfill. +7. **Docs polish, typography, colors, README tweaks.** Only if a user + reports confusion. Otherwise skip. +8. **Test coverage for its own sake.** Don't. 212 tests already cover + the load-bearing paths. Only add tests when touching the code. + +--- + +## 7. Hard red lines — do not do any of these + +- Do not add a new runtime dependency without justifying it in the PR + body with (a) why, (b) bundle-size cost, (c) alternatives rejected. + This is a local CLI — every dep is a supply-chain risk. +- Do not add telemetry, analytics, error reporting, or any network call + the user didn't opt into. **First principle of the product.** +- Do not introduce a new top-level config file. Use + `~/.agentwatch/.json` pattern (see triggers.json, budgets.json). +- Do not rewrite the reducer architecture. It's 47 tests of stability. +- Do not change CLI flags or the `agentwatch doctor` output contract — + those are the semver-breaking surface (per CHANGELOG). +- Do not bump the version or publish to npm. Ever. That's human-only. +- Do not create a new Linear project. If no existing project fits, flag + it in the Telegram ping and stop. +- Do not delete a feature contract without a feature's removal being + decided explicitly by the human. +- Do not open PRs that touch more than ~200 lines unless the Linear + issue explicitly scopes it that large. If it grows, split it. +- Do not auto-merge. Ever. +- Do not push to `main` directly. Branch protection will reject it, but + don't even attempt. Branch → PR → human merges. +- Do not comment on GitHub issues/PRs with prose that sounds like the + maintainer. Labels are fine; drafts go to Linear + Telegram. +- Do not `@`-mention anyone in a GitHub comment. Michael gets Telegram. +- Do not run hang-prone shell commands without an explicit timeout. + Use the `wt ` helper (see prompt.md → *Timeouts on + hang-prone commands*). The 2026-04-21 17-minute timeout + (run 981bbbf1…) burned the whole cron window because a single + command blocked unbounded — the cron 15-min backstop is not a + substitute for per-command discipline. + +--- + +## 8. Promotion drafts — the rules + +Audience: multi-agent coding operators. People on r/ClaudeAI, +r/LocalLLaMA, HN, X/devtwitter. They are the author's peers. They smell +marketing from a mile away and will dunk on you if the post is generic. + +### Voice — match the existing voice + +From the repo (quote these in drafts when you need voice calibration): + +- "When something goes wrong — a file rewritten unexpectedly, a spend + spike, an `rm` you don't remember running — you're piecing it together + from five JSONLs and guessing." +- "`claude-devtools` is a great tool for Claude-only workflows — if you + only use Claude Code, it's probably the better pick." +- "Not cloud. Not SaaS. Not ever." +- Commit: "batched dispatches + memoized derived state + fs-watcher + opt-in" + +Dry. Technical. Leads with the problem, not the pitch. Unafraid to name +competitors and say where they're better. No emoji. No em-dashes for +drama (the repo uses them for asides, not flair). No "revolutionary." +No "game-changing." + +### Good post / bad post + +**Bad** (will get downvoted): +> 🚀 Introducing agentwatch — the revolutionary new way to monitor your +> AI coding agents! One unified dashboard for all your tools. Try it +> today! [link] + +**Good** (matches the voice): +> I run Claude Code + Codex + Gemini CLI on the same laptop and I could +> never tell which one wrote what file, or where my $40 went at the end +> of the day. So I built a local TUI that reads all their session logs +> and puts them on one timeline with real cost math. No cloud, no +> account. Limitations up front: Cursor is config-only for now, and +> Gemini doesn't persist compaction markers. [link] + +### Channels + rules + +| Channel | Rules the agent must respect | +|---|---| +| r/ClaudeAI | No low-effort self-promo. Lead with the multi-agent angle, not just Claude. | +| r/LocalLLaMA | Local-only is the selling point here. Emphasize no-cloud, no-telemetry. | +| r/commandline | TUI detail matters. Lead with the terminal demo. | +| Hacker News (Show HN) | Title format: `Show HN: agentwatch – …`. No hype words. Technical first paragraph. Be ready to answer why not Langfuse/claude-devtools. | +| X/Twitter | Thread format (3–5 tweets). First tweet is the hook problem. Last tweet is the repo link. | + +For every draft, include in the Linear issue: +- **Channel:** [r/LocalLLaMA] +- **Target URL:** [https://reddit.com/r/LocalLLaMA/submit] +- **Title:** [verbatim text] +- **Body:** [verbatim markdown] +- **Rule reminder:** [one-line reminder of the subreddit's posting rules] +- **Voice check:** [one sentence explaining what in the post matches the + repo voice — if you can't write this, the post is generic, rewrite] + +### Hard rules for drafts + +- Never fabricate metrics. "Used by 1000 operators" is banned unless the + number is real. +- Never claim features that aren't shipped. Cross-check against + `docs/features/`. +- Always include a limitation or non-goal in the post — it makes the + pitch credible and matches the repo's honesty. +- Never target a channel the author has already posted to in the last + 14 days (check `git log` and Linear `promotion-draft` history). +- If you don't have a specific reason this post belongs on this channel + right now, don't draft it. + +--- + +## 9. Linear issue hygiene + +- Project: **Product — agentwatch** unless something else clearly fits. +- Title: imperative verb, <70 chars, no trailing period. +- Description has three sections: Context / Acceptance criteria / Links. +- Labels from the canonical set only: `urgent`, `agent-ready`, + `ai-refinement`, `blocked`, `promotion-draft`. Do not invent labels. +- **Always `list_issues` first** and grep for overlap before creating. +- When you create an issue from a GROOM run, label it `ai-refinement`. + You are not permitted to self-promote an issue to `agent-ready` — + only the human can. +- Link related issues (`blocks` / `blockedBy` / parent) when the + relationship is real. + +--- + +## 10. Commit and PR voice + +Commit message format (match the existing history): + +``` +(): + + +``` + +Real examples from `git log` to calibrate on: + +- `feat(AUR-204): scheduled tasks observability — cron + heartbeat` +- `refactor(ui): extract reducer to src/ui/state.ts + 47 tests` +- `perf: batched dispatches + memoized derived state + fs-watcher opt-in` +- `fix(M7): index OpenClaw sessions in semantic search (was missed in AUR-181)` + +No "chore: update stuff." No emoji. No "🤖 Generated with Claude Code" +footer — the author dislikes it in this repo. + +PR titles follow the same shape as commit messages. PR bodies are short +— what / why / test evidence. No templated sections. + +--- + +## 11. Session end — always + +Every run ends with: + +1. Update the relevant Linear issue(s): `In Progress` if you started + work, comment on status. Don't mark `Done` — the human does. +2. **Persist every write you made, in every repo you touched:** + - For `~/IdeaProjects/agentwatch/`: branch → commit → push → open + a PR against `main` in IMPLEMENT mode. Never push to `main`. + - For `~/IdeaProjects/knowledge-base/` (if you wrote to it at any + point during this run — audits, reports, notes, anything): + `cd ~/IdeaProjects/knowledge-base && git add + && git commit -m "" && git push origin main`. The + KB is not a code repo — commit straight to `main`, no PR needed. + - For any other repo you wrote to: at minimum commit + push. If + unsure whether pushing is safe, stop and Telegram-ping the human. + - **A run that leaves untracked files or unpushed commits in any + repo is a failed run.** Writing without persisting defeats the + purpose of running at all. +3. Send the Telegram message with a one-line summary and the + Linear/PR URL. Include the KB commit SHA (short) if you committed + anything to the KB this run. +4. **Ambiguity is not a blocker.** If requirements are ambiguous or + context is missing, pick the most reasonable interpretation, + document the assumption in the PR description under "Assumptions", + and ship. The human overrides in review. + + Use `[BLOCKED]` ONLY for hard blockers: broken credentials, API + unavailable, test infrastructure missing, environment failure. + These are things that block **the mechanical act of shipping**, + not things that merely require judgment. Your judgment is + cheaper than another daily cycle. + + When you do hit a true hard blocker, create a Linear issue with + `blocked` label, Telegram-ping the human with `[BLOCKED]`, and + exit clean. Still commit+push anything you already wrote before + stopping — partial work belongs on the remote, not in local + uncommitted state. **A `[BLOCKED]` exit with a dirty working tree + is itself a failure**: clean it (commit+push, or stash) before + pinging. + +--- + +## 12. When in doubt + +Ask: *is this on the critical path to knowing what every agent on this +machine is doing?* + +If yes → proceed. +If no → don't do it. File it as an `ai-refinement` issue and stop. + +The repo's entire strength is that it says no to scope creep. Embody +that. Be boring on purpose. Ship less than you could. The author would +rather review one sharp PR than three pointless ones. diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb737c..8514adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,307 @@ layout can change freely within a minor version. ## [Unreleased] +## [0.1.0] — 2026-05-03 + +### Added — v0.1 foundation + +- **SQLite event store** (AUR-263) at `~/.agentwatch/events.db` — replaces + the 4 MB rolling backfill with a persistent indexed store. WAL mode + + `synchronous=NORMAL` + FTS5 virtual table over prompt/response/ + thinking/tool_result/summary. Migrations are versioned (`schema_version` + table). Three tables: `events` (canonical AgentEvent), `sessions` + (auto-aggregated via trigger on event insert: cost, ts range, count, + project), `tool_calls` (tool, duration, error). Bench: ingests 10k + events in ~430ms on M1 air. +- **`agentwatch prune --older-than-days N`** — drops events older than + the cutoff (default 90 days), VACUUMs the DB on non-trivial prunes, + prints the resulting size. +- **Search history mode** — `POST /api/search` with `mode: "history"` + hits the FTS5 index for full-history matches (vs the live ring buffer + or the JSONL cross-scan). Returns FTS-ranked snippets. +- **Background daemon** (AUR-262) — `agentwatch daemon start | stop | + status | logs` installs a launchd LaunchAgent (macOS) or a systemd + user unit (Linux) that runs the adapter pipeline 24/7, writing every + event into `~/.agentwatch/events.db`. The TUI and `agentwatch serve` + are now read clients of the same store, so events captured overnight + are visible the moment you open them. PID file + start-time at + `~/.agentwatch/daemon.{pid,started_at}`; log at + `~/.agentwatch/daemon.log` with a 10 MB single-slot rotation; PID + re-acquire is stale-PID-aware (process-alive probe). +- **Per-event activity classifier** (AUR-264) — every event lands with + `details.category` ∈ {coding, debugging, exploration, planning, + refactor, testing, docs, chat, config, review, devops, research}. + Heuristic ladder over file-extension / tool-name / shell-command / + prompt+response keyword signals; argmax wins. Schema v2 adds a + `category` column + index. New routes: + `GET /api/sessions/:id/activity`, `GET /api/projects/:name/activity`. +- **Git-correlation yield views** (AUR-265) — pairs commits with the + sessions whose `[first_ts, last_ts + 30min]` window contains the + commit's author date, then surfaces $/commit, $/line-changed, total + insertions/deletions/files. Per-project view also returns a sorted + "spend without commit" list of sessions that burned dollars but + produced no commits in window. Worktree de-dup via `gitCommonDir()`. + Read-only: git verbs are allow-listed (`log`, `rev-parse`, `worktree`, + `show`, `diff`, `blame`, `status`, `config`, `branch`, `remote`). + New routes: `GET /api/sessions/:id/yield`, + `GET /api/projects/:name/yield`. +- **Claude Code native hooks adapter** (AUR-266) — agentwatch can + register itself as a Claude hook and receive events ~1–2s faster + than the JSONL transcript, with no sub-event drops. Translates + every known Claude hook type (SessionStart, SessionEnd, + UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, + PreCompact, PostCompact, Notification) into the canonical + AgentEvent shape; unknown future types fall through to a generic + `tool_call` so new releases don't silently drop. CLI: `agentwatch + hooks {install | uninstall | status}`. Settings stanzas are tagged + with `[agentwatch-managed]` so uninstall only removes our entries + and preserves user-configured hooks. A 5-second `(sessionId, + toolUseId)` dedup window drops the duplicate JSONL copy when both + paths fire. `agentwatch doctor` now reports + `claude code hooks: installed | not-installed | partial`. + +### Changed + +- **EventSink wired through the store** in both TUI and `serve` modes — + every emit/enrich is now mirrored to SQLite. Failures are logged once + and never propagated; the in-memory pipeline remains the source of + truth for the live SSE stream. + +### Docs + +- **ROADMAP.md** — v0.1 → v1.0 direction, milestone gates, scope + commitments. +- **glama.json + Glama badges** — MCP-registry profile so agentwatch + shows up in Glama's directory. + +## [0.0.5] — 2026-05-01 + +### Added +- **Externalized pricing** via `~/.agentwatch/pricing.json` (AUR-216) — per-model + rates can be overridden without rebuilding the binary; the in-tree defaults + are loaded as a fallback when the file is absent. +- **OpenClaw `toolResult` pairing** (AUR-217) — toolResult turns now back-fill + the originating toolCall with output, duration, and error flag, matching + Claude/Codex parity. +- **Unparseable JSONL lines surfaced** as a structured `parse_error` event + (AUR-228) instead of being silently swallowed; you now see when an adapter + is choking on malformed input. + +### Fixed +- **Partial JSONL lines preserved across reads** (AUR-227) — a chunk boundary + in the middle of a line no longer truncates the event; tail buffer holds + the partial line until the newline arrives. +- **Version no longer drifts between `package.json` and runtime** — `--version` + reads `package.json` instead of a hardcoded constant. +- **`bin/agentwatch.js` is executable** in the published tarball + (a `chmod +x` was missing from the build). + +### Docs +- Documented that Gemini CLI and OpenClaw do not persist compaction markers + to disk (AUR-214) — this is a structural limit of what those agents write, + not a missing adapter feature. + +### Internal +- AGENT_DIRECTIVES.md hardening for the autonomous agentwatch-bot harness + (AUR-241 timeout wrappers, AUR-242 defensive `last-triage.txt` initializer). + These are agent-harness changes only; no user-facing impact. + +## [0.0.3] — 2026-04-15 + +### Added — full multi-agent + moats wave + +- **M5 parity-with-claude-devtools** — token attribution per turn + (gpt-tokenizer cl100k_base; CLAUDE.md / AGENTS.md / GEMINI.md / + .cursorrules / .windsurfrules / OPENCLAW.md as memory-file overhead), + context compaction visualizer (`C`), syntax highlighting in detail + pane (`cli-highlight`), session export to markdown + JSON (`e`), + stale-session detection (`⊘ stale` after 5 min idle). +- **M6 differentiation moats** — user-defined regex / threshold + notification triggers in `~/.agentwatch/triggers.json` (live-reloaded), + per-session and per-day budget alarms in `~/.agentwatch/budgets.json` + (banner + OS notification on breach), MCP server mode (`agentwatch mcp`) + exposing 5 tools (`list_recent_sessions`, `get_session_events`, + `search_sessions`, `get_tool_usage_stats`, `get_session_cost`), + OpenTelemetry exporter (`AGENTWATCH_OTLP_ENDPOINT`) with `gen_ai.*` + semantic conventions, cross-session search across every JSONL/JSON + on disk. +- **M7 anomaly detection + semantic search** — MAD z-score outliers + on cost / duration / tokens (configurable in + `~/.agentwatch/anomaly.json`), period-1-to-4 stuck-loop detector, + per-session aggregation with banner + dismiss (`D`) + timeline `◎` + marker. Hybrid semantic search (`?` then `s`) using + `bge-small-en-v1.5` (q8) via `@huggingface/transformers` v3 + SQLite + FTS5 + Reciprocal Rank Fusion. First-run consent prompt before any + download. +- **Codex adapter** — full session parsing including + `function_call` / `function_call_output` pairing (toolResult, + duration, error flag), `event_msg/token_count` enrichment, model + capture from `session_meta` + `turn_context`, GPT-5 / GPT-5-mini + cost rates. +- **Gemini adapter** — full token usage from each `gemini` message, + `toolCalls[]` parsing into file_read / file_write / shell_exec / + tool_call with inline functionResponse output, gemini-2.5-pro / flash + cost rates. +- **OpenClaw adapter** — surfaces the precomputed `usage` + `cost` + block from each assistant message (cacheWrite → cacheCreate map). +- **Codex + Gemini permission views** — `~/.codex/config.toml` projects + + sandbox_policy from latest session's `turn_context`, + `~/.gemini/settings.json` auth + tools allow/block + trusted folders. + +### Changed +- README rewritten end-to-end to match shipped reality. +- Per-event cost / OTel spans now use the `gen_ai.*` OpenTelemetry + semantic conventions instead of agentwatch-only attribute names. +- Codex / Gemini / OpenClaw memory files (AGENTS.md, GEMINI.md, + .cursorrules, OPENCLAW.md) are read for token-attribution overhead + alongside the existing CLAUDE.md. + +### Performance +- Event dispatches coalesced at 16 ms during backfill — 500 emits + collapse to 1 batched merge, removing render thrash on launch. +- All derived passes (anomaly, budget, projectIndex, sessionRows, + childCountByAgentId) wrapped in `useMemo` — no longer recomputed + on every keypress. +- Anomaly scoring now precomputes per-agent histories once instead of + `slice + filter` per event. + +### Behavior +- The generic file-system watcher of `WORKSPACE_ROOT` is now opt-in + via `AGENTWATCH_WATCH_WORKSPACE=1`. On large monorepos it could + exhaust inotify / take seconds to establish watches. Agent events + (Claude / Codex / Gemini / OpenClaw) are unaffected. +- `q` / Ctrl-C now restore stdin raw mode reliably — the shell no + longer freezes for ~1 minute after exit. + +### Cancelled (with documented reasoning in Linear) +- Diff-attribution (AUR-182), cross-agent session correlation + (AUR-183), replay mode (AUR-184). See ticket descriptions for the + research record. + +## [0.0.2] — 2026-04-14 + +### Added — claude-devtools parity (v0.3 feature wave) +- **Event detail pane** (`Enter`) — full-screen view of any event with + tokens, cost, duration, tool input, tool result, full text, extended + thinking. Scrollable with `↑↓` / `j k`. +- **Full-text search** (`/`) — narrows the timeline by summary / path / + cmd / tool / agent / full text / thinking. Live match count. +- **Projects grid** (`P`) — one row per workspace on your machine, + per-agent event counts, total cost, last-active time. +- **Sessions list** — bucketed by Today / Yesterday / Last 7 days / + Older. Each row: agent tag, first user prompt, event count, + duration, cost, error flag. +- **Scoped session timeline** — Enter a session to filter the main + timeline to just that session's events. +- **Subagent drilldown** (`x`) — scope the timeline to the inner tool + calls of a selected `Agent` spawn. `X` unscopes. Parent events show + `▸ N child events` suffix. +- **Subagent JSONL ingestion** — `~/.claude/projects/*/SESSION/subagents/agent-*.jsonl` + is now captured (previously invisible; 8.5k events surfaced on a + typical dev machine). +- **Per-session cost with cache-hit accounting** — per-model rates + (opus-4-6, sonnet-4-6, haiku-4-5) correctly weighting + `cache_creation_input_tokens` (125%) and `cache_read_input_tokens` + (10%). Naive summers are 3–10x wrong without this. +- **tool_use ↔ tool_result pairing** — captures duration, full output + content, error flag for every Claude tool call. +- **Desktop notifications** — built-ins fire for `.env` access, + `~/.ssh`/`.aws`/`.gnupg` paths, `rm -rf` / `sudo` / `curl | sh`, and + tool errors. Rate-limited, backfill-silent. +- **Yank to clipboard** (`y`) — copies the most useful payload (tool + result > full text > cmd > path) via `pbcopy` / `wl-copy` / `xclip` + / `clip`. +- **Help overlay** (`?`) — grouped keybindings reference from any view. +- **Breadcrumb header** — surfaces active view + every active scope + (project, session, subagent, agent, search). +- **Per-agent permission viewer** extended to Cursor (approval mode, + sandbox, allow/deny, MCP servers, `.cursorrules`) and OpenClaw + (default workspace, per-sub-agent model + workspace). + +### Added — scaffold / discipline +- **CONTRIBUTING.md**, **SECURITY.md**, **CODE_OF_CONDUCT.md**. +- **Issue + PR templates** (bug, feature, adapter request). +- **`0` = home** — reset all filters / scopes / modals. +- **`Z` = clear filters** — replaces the confusing `A` case-variant. +- **`esc` = go back one level** — consistent across every view. + +### Fixed +- **Claude adapter was reading zero events.** chokidar v4 dropped glob + support; the `${dir}/**/*.jsonl` pattern never fired. Now watches + the projects dir recursively with a path regex. Reveals thousands of + events that were invisible in 0.0.1. +- **EMFILE crash** after ~30s of real use. Reduced FS watcher depth + from 8 → 3, expanded ignores (coverage, `.venv`, `__pycache__`, + `.turbo`, lock files), replaced Cursor's recursive workspace watcher + with a one-shot shallow discovery + per-file watcher. All adapters + silently swallow EMFILE / ENOSPC / EACCES instead of crashing. +- **`q` felt laggy** — chokidar's close waits on pending FDs. We now + force `process.exit(0)` on quit. +- **Timeline rendered in arrival order**. Backfill arrived out of + order. Now binary-inserted by event timestamp — strict + reverse-chronological. +- **Empty-content events polluted the timeline.** Assistant messages + with neither text nor tool_use are now suppressed. +- **Clipboard + notifier EBADF inside the TUI** — Ink's raw-mode TTY + broke inherited stdio on spawnSync. Explicit pipe / ignore stdio on + all child process calls. +- **fs-watcher double-counted Claude writes.** Introduced a + module-scoped `recentAgentWrites` cache; fs-watcher skips paths + an agent wrote within the last 5s. + +### Changed +- Event `summary` now includes `[project]` prefix (Claude: extracted + from session path; OpenClaw: from `cwd` in `session_start`; Cursor: + path heuristic). +- Assistant tool_use events now extract real payload into summary + instead of literal `tool_call`: `Bash: git log`, `Read: src/auth.ts`, + `Task: refactor parser`, etc. +- Classification: Bash tool_use is now `shell_exec` (not `tool_call`) + with elevated risk scoring for destructive commands. + +### Notes +- Not cloud. Not an agent. Not telemetry-enabled. Zero outbound + network calls. +- macOS + Linux. Windows intentionally out of scope for v0. +- Codex and Gemini adapters are intentionally deferred. + +## [0.0.1] — 2026-04-14 + +### Fixed +- **Claude adapter silently reading zero events.** chokidar v4 dropped glob + support; the `${dir}/**/*.jsonl` pattern never fired. Now watches the + projects dir recursively with a path-regex filter. Live smoke surfaced + thousands of events where 0.0.1 showed none. +- **EMFILE crash** after ~30 seconds of real use. Reduced FS watcher depth + from 8 → 3, expanded the ignore list (coverage, `.venv`, `__pycache__`, + `.turbo`, lock files), and replaced Cursor's recursive workspace watcher + with a one-shot shallow discovery + per-file watcher. All adapters now + silently swallow EMFILE / ENOSPC / EACCES instead of crashing. +- **`q` felt laggy.** chokidar's close waits on pending FDs; we now force + `process.exit(0)` on quit so the shell returns immediately. +- **Timeline rendered in arrival order** (backfill out of order). Events + are now binary-inserted by `ts` so the view is strictly reverse- + chronological regardless of which file arrived first. +- **Empty-content events polluted the timeline.** Assistant messages with + no text and no tool_use, and user turns made up only of tool_results, + are now suppressed. + +### Added +- **Project prefix on every event** — `[auraqu]`, `[_content_agent_]`, + `[reachout]`. Claude events derive the project from the session path; + OpenClaw tracks cwd per session from `session_start`; Cursor uses path + heuristics. Finally makes it possible to see *where* each agent is + working at a glance. +- **Rich Claude tool_use summaries** — Bash tool uses render as + `Bash: ` with correct `shell_exec` type and risk scoring; + Read/Write/Edit/MultiEdit render as `: `; Grep/Glob include + the pattern; Task includes the description; WebFetch includes the URL. +- **Sticky column header** at the top of the timeline (`TIME / AGENT / + TYPE / EVENT`). +- **Alt-screen buffer** — agentwatch now takes over the viewport on + startup and restores the shell scrollback on exit. Standard TUI + behaviour (lazygit / k9s / htop). + ## [0.0.1] — 2026-04-14 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..202d06a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +agentwatch adopts the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). +The summary below is the short version; the full text is authoritative. + +## Short version + +Be respectful. Assume good faith. Disagree with ideas, not people. No +harassment, discrimination, or targeted disparagement in issues, PRs, or +discussions. Private DMs are outside the scope of this policy but the same +expectations apply if you represent the project. + +## Reporting + +Email `misha@auraqu.com`. Reports are confidential and acted on in good faith. + +## Enforcement + +Maintainers may remove comments, close issues, or ban accounts that violate +this code. Egregious or repeated violations lead to permanent bans. + +The full Contributor Covenant text applies: + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b737a9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing to agentwatch + +Thanks for taking a look. agentwatch is in early v0 — most "should we build X?" +answers are in the [roadmap](https://linear.app/auraqu/project/agentwatch-748d6aa1c20a) +(public-readable) or just open an issue and ask. + +## What's welcome + +- **Bug reports** with repro steps. See `.github/ISSUE_TEMPLATE/bug_report.md`. +- **Adapter requests** — a new AI agent or coding CLI we don't support yet. + See `.github/ISSUE_TEMPLATE/adapter_request.md`. +- **Small PRs** fixing issues, improving docs, tightening types. I'll merge fast. +- **Dogfood notes.** Open a discussion (not an issue) with "what felt slow / + confusing / wrong" — I read every one. + +## What to pause on + +- **Large feature PRs without a prior issue.** The roadmap is opinionated and + scope is deliberately narrow (local-only, TUI-first, multi-agent). Please + open an issue first so I can tell you if it's in-scope before you invest + hours. +- **Integrations that ship data off-machine.** agentwatch is local-only by + principle. An "upload to …" PR will be closed. + +## Feature gate (for new features) + +Every user-visible feature needs a contract written *before* the code, and +a test that exercises it *before* it merges. The contract lives at the top +of `docs/features/.md` with three fields: + +```markdown +## Contract + +**GOAL:** One line. What the feature accomplishes. +**USER_VALUE:** One line. Why a user cares. If this is generic ("better UX"), +the feature is bloat — don't build it. +**COUNTERFACTUAL:** One line. What breaks if this feature is removed. This +defines the testable regression surface. +``` + +`src/util/feature-contract.test.ts` fails CI if any `docs/features/*.md` +file is missing a field. It can't enforce *quality* of the three fields — +review is the second gate. If `USER_VALUE` could fit any feature or +`COUNTERFACTUAL` is "nothing breaks," that's a kill signal. + +For the test side: non-trivial reducer changes should land a test in +`src/ui/state.test.ts`. UI-rendering regressions that aren't reducer-shaped +still fall back to the manual walkthrough in `docs/testing/TEST-SCRIPT.md`. + +## Dev setup + +```bash +git clone https://github.com/mishanefedov/agentwatch.git +cd agentwatch +npm install +npm run dev # launches the TUI directly from source +npm test # runs vitest +npm run typecheck # strict TS +npm run build # produces dist/ via tsup +``` + +Node ≥ 20, macOS / Linux. Windows is intentionally out of scope for v0. + +## Code shape + +- `src/schema.ts` — canonical `AgentEvent` type. Every adapter emits through + `EventSink`. +- `src/adapters/` — one file per agent (Claude Code, OpenClaw, Cursor, + filesystem). Each returns a `stop()` function. +- `src/ui/` — ink-based TUI components. +- `src/util/` — shared helpers (cost, project index, clipboard, notifier, + workspace detection, permissions parsers). + +Every adapter: + +- Reads local files read-only +- Never calls the network +- Handles its own watcher errors without crashing the process + +## PR checklist + +- [ ] `npm run typecheck` passes +- [ ] `npm test` passes (includes the feature-contract gate) +- [ ] If the PR adds a user-visible feature: contract block added to + `docs/features/.md` and a test asserts its `COUNTERFACTUAL` +- [ ] Added a test if the change is non-trivial +- [ ] CHANGELOG.md updated if the change is user-visible +- [ ] Commit message describes *why*, not just *what* + +## Communication + +- Issues for bug reports + scoped feature requests +- Discussions for "have you considered…" conversations +- Email `misha@auraqu.com` for anything sensitive + +## License + +By contributing, you agree your contribution is licensed under MIT (same as +the rest of the project). diff --git a/README.md b/README.md index ac985bd..07ec09b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,115 @@ +
+ # agentwatch -**Local-only observability for AI coding agents.** One terminal timeline across Claude Code, Cursor, and OpenClaw — what each agent is reading, writing, running, and what each is actually allowed to do. +**Local observability + control plane for every AI coding agent on your machine.** + +A terminal live-tail *and* a browser dashboard — one process, one event +stream, served from `localhost`. Unified timeline across Claude Code, +Codex, Gemini CLI, Cursor, Hermes, and OpenClaw. Token + cost accounting, +compaction + anomaly detection, hybrid search, SVG call graphs, +monaco-style diff attribution, agent-aware replay ("what would the agent +say if I edited the prompt?"), policy editor, MCP server agents can query +their own history from, and an OpenTelemetry exporter with `gen_ai.*` +semantic conventions. All local. No cloud. No telemetry. No sign-in. + +[![npm](https://img.shields.io/npm/v/@misha_misha/agentwatch.svg)](https://www.npmjs.com/package/@misha_misha/agentwatch) +[![CI](https://github.com/mishanefedov/agentwatch/actions/workflows/ci.yml/badge.svg)](https://github.com/mishanefedov/agentwatch/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Node >=20](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)](./package.json) +[![MCP server](https://glama.ai/mcp/servers/mishanefedov/agentwatch/badges/score.svg)](https://glama.ai/mcp/servers/mishanefedov/agentwatch) + +
+ +
+ +[![agentwatch on Glama](https://glama.ai/mcp/servers/mishanefedov/agentwatch/badges/card.svg)](https://glama.ai/mcp/servers/mishanefedov/agentwatch) + +
+ +
+ agentwatch web UI — unified timeline across 5 agents, each in its own workspace +
+ agentwatch event detail view — full command, tool I/O, usage + cost +
+ +**The TUI is the live tail. The web UI is where you drill in** — projects, +sessions, token charts, compaction sparklines, SVG call graphs, diff +attribution, replay, anomaly triage, policy editing. Both run in one +process. Press `w` in the TUI to open the browser. + +--- + +## Table of contents + +- [Why this exists](#why-this-exists) +- [Install](#install) +- [First 60 seconds](#first-60-seconds) +- [Agent coverage](#agent-coverage) +- [Features](#features) +- [Keyboard reference](#keyboard-reference) +- [Configuration](#configuration) +- [What agentwatch reads](#what-agentwatch-reads) +- [MCP server mode](#mcp-server-mode) +- [OpenTelemetry exporter](#opentelemetry-exporter) +- [How it compares](#how-it-compares) +- [Limitations](#limitations) +- [Non-goals](#non-goals) +- [Architecture](#architecture) +- [Development](#development) +- [Security](#security) +- [License](#license) + +--- + +## Why this exists + +You run three AI coding agents on one laptop. Claude Code in a terminal, +Codex alongside it, Cursor as your IDE, maybe Gemini CLI for a quick +review, maybe an OpenClaw sub-agent churning on a long task. Every one of +them has its own log file, its own permission model, its own idea of what +a "session" is. None of them tells you what the others are doing. + +When something goes wrong — a file rewritten unexpectedly, a spend spike, +an `rm` you don't remember running — you're piecing it together from five +JSONLs and guessing. + +[`claude-devtools`](https://github.com/matt1398/claude-devtools) does this +well for Claude Code. **agentwatch does it for the whole multi-agent +stack, in the terminal, with zero infrastructure and zero network.** + +--- + +## Why this over `claude-devtools` if you run multiple agents? -No cloud. No Docker. No telemetry. `npm i -g agentwatch` and go. +Short, factual diff. `claude-devtools` is a great tool for Claude-only +workflows — if you only use Claude Code, it's probably the better pick. +agentwatch is the answer when you run more than one agent on the same +machine and want one timeline + one cost ledger + one alerting surface +across all of them. -## Why +| What | claude-devtools | **agentwatch** | +| -------------------------------------------- | ----------------------- | ------------------------------------- | +| Claude Code coverage | ✅ full | ✅ full | +| Codex coverage | ❌ | ✅ tokens + tools + cost + compaction | +| Gemini CLI coverage | ❌ | ✅ tokens + tools + cost | +| OpenClaw coverage | ❌ | ✅ tokens + cost | +| Hermes Agent coverage | ❌ | ✅ tokens + tools + cost (SQLite) | +| Cursor coverage | ❌ | 🟡 config level | +| Per-agent budget alarms | ❌ | ✅ session + daily caps | +| Statistical anomaly detection (loops / spikes) | rule-based only | ✅ MAD z-score + period-1-to-4 loops | +| OpenTelemetry exporter (`gen_ai.*`) | ❌ | ✅ Jaeger / Tempo / Grafana ready | +| MCP server — agents query their own history | ❌ | ✅ 5 tools over stdio | +| User-defined regex/threshold triggers | ❌ | ✅ live-reloaded | +| Install | Homebrew / Electron ~150 MB | `npm i -g` · 220 KB · TUI | +| Data boundary | local | local | -You're running Claude Code + Cursor + OpenClaw on the same machine. Each has its own config (`CLAUDE.md`, `.cursorrules`, OpenClaw workspaces), its own activity log in a different place, and its own permission model. Nothing shows you one unified view: *right now, which agent just touched which file, ran which command, and why.* +If "every agent on one pane of glass + programmatic access via MCP + +pipeline-friendly OTel" matches your setup, agentwatch is the tool. +If you're Claude-only and want the Electron polish, `claude-devtools` +is still excellent. -[claude-devtools](https://github.com/matt1398/claude-devtools) solves this beautifully — for Claude only. agentwatch does the same thing for the whole multi-agent setup. +--- ## Install @@ -17,67 +118,539 @@ npm i -g @misha_misha/agentwatch agentwatch ``` -That's it. No config, no accounts, no daemon. (Published under a -scope because `agentwatch` was blocked by npm's anti-typosquatting -check — the binary is still `agentwatch`.) +Requires: -Requires Node ≥ 20. Works on macOS and Linux. +- **Node ≥ 20** (tested on 20 + 22 in CI) +- **macOS or Linux** (Windows intentionally out of scope for v0.x) -## What it shows +Published under the `@misha_misha` npm scope — the unscoped `agentwatch` +name was already taken by a CyberArk tool. The installed binary on your +`$PATH` is simply `agentwatch`. -- **Claude Code** — tails `~/.claude/projects/**/*.jsonl` and emits every prompt, response, tool call, file read/write, and shell exec with attribution and risk scoring. -- **OpenClaw** — watches `~/.openclaw/agents/*/sessions/*.jsonl` across every sub-agent (content, research, docs, main) with sub-agent attribution in the event stream, plus `config-audit.jsonl` with elevated risk scoring for config writes. -- **Cursor** — config-level visibility: MCP server list, permissions (`cli-config.json`), recently-viewed files (`ide_state.json`), discovered `.cursorrules` anywhere in your workspace. -- **Workspace filesystem** — chokidar-backed watcher over `$WORKSPACE_ROOT` (default `~/IdeaProjects`) with sensible ignores (`node_modules`, `.git`, `dist`). -- **Permissions (Claude)** — press `p` in the TUI to open a full-screen view of `~/.claude/settings.json`. Renders the allow / deny lists, `defaultMode`, and flags dangerous patterns: `Bash(*)`, missing `~/.ssh`/`.aws`/`.gnupg` denies, auto/bypass modes. +--- -## Hotkeys +## First 60 seconds + +```bash +agentwatch doctor # detects installed agents + readiness +agentwatch # TUI live-tail + web UI at http://127.0.0.1:3456 +agentwatch serve # web UI only (remote boxes / server cron) +agentwatch mcp # runs the MCP stdio server (for agents, not humans) +agentwatch --help +``` + +Flags: + +- `--no-web` — TUI only, don't start the web server +- `--port ` / `--host ` — override web server bind +- `AGENTWATCH_PORT=… AGENTWATCH_HOST=…` — env equivalents + +`doctor` output looks like: ``` -q quit -a toggle agent side panel -f cycle agent filter -p toggle full-screen permission view -space pause / resume event stream -c clear events +workspace: /Users/you/IdeaProjects + +agents: + ● Claude Code installed (events captured) + ● Codex installed (events captured) + ● Gemini CLI installed (events captured) + ● Hermes Agent installed (events captured) + ● Cursor installed (config-level only) + ● OpenClaw installed (events captured) + ○ Aider not detected + ○ Cline (VS Code) not detected ``` -## CLI +Launch `agentwatch` and every event your agents emit streams in. The TUI +shows a live tail; the web UI at `http://127.0.0.1:3456` is where you +drill in — projects, sessions, token charts, SVG call graphs, diff +attribution, prompt replay, trends. Press `w` in the TUI to open it. + +### Web UI map + +| Route | What it is | +| ------------------------------------ | ------------------------------------------------------- | +| `/` | Live timeline (SSE-streamed) with agent + type filters | +| `/projects` | Grid of detected projects + cost + session counts | +| `/projects/:name` | Sessions table for one project | +| `/sessions/:id` | Chronological event list · export .md / .json | +| `/sessions/:id/tokens` | Stacked-area token chart per turn | +| `/sessions/:id/compaction` | Context fill % over time + compaction markers | +| `/sessions/:id/graph` | Call graph (d3-hierarchy SVG) — click nodes to drill | +| `/sessions/:id/diffs` | Writes paired with the prompt that triggered them | +| `/sessions/:id/replay` | Edit prompt → re-run the agent in single-turn exec | +| `/search` | Unified search (live / cross / semantic) | +| `/agents` | Grid of every supported agent + install status | +| `/permissions` | Per-agent permission config | +| `/cron` | OpenClaw cron jobs + heartbeats | +| `/trends` | Cost, cache-hit ratio, events per agent (30d default) | +| `/settings/{budgets,anomaly,triggers}` | Form editors for `~/.agentwatch/*.json` | + +`⌘K` / `Ctrl+K` opens the command palette. +`/` focuses the timeline filter. + +--- + +## Agent coverage + +What actually works per agent, as of v0.0.3. Features not listed here +work across every agent (timeline, export, syntax highlighting, notifications, +triggers, search, stale detection, clipboard yank). + +| Feature | Claude Code | Codex | Gemini CLI | Cursor | OpenClaw | Hermes | +| ------------------------------ | :---------: | :---: | :--------: | :----: | :------: | :----: | +| Live events on timeline | ✅ | ✅ | ✅ | 🟡 | ✅ | ✅ | +| Token usage + cost | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Tool call + result pairing | ✅ | ✅ | ✅ | ❌ | 🟡 | ✅ | +| Per-turn token attribution | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Budget alarms (session + day) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Anomaly detection (cost/loops) | ✅ | ✅ | ✅ | 🟡 | ✅ | ✅ | +| Compaction visualizer | ✅ | ✅ | ❌ | — | ❌ | ❌ | +| Permissions view | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| Cross-session search | ✅ | ✅ | ✅ | ❌ | ❌ | 🟡 | +| Subagent drilldown | ✅ | — | 🟡 | — | 🟡 | 🟡 | +| Replay (agent-aware exec) | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Agent memory file overhead | `CLAUDE.md` | `AGENTS.md` | `GEMINI.md` | `.cursorrules` | `OPENCLAW.md` | `SOUL.md` | +| OTel span coverage | ✅ | ✅ | ✅ | 🟡 | ✅ | 🟡 | +| MCP server exposes history | ✅ | ✅ | ✅ (raw) | ❌ | ❌ | ❌ | + +- **Cursor** exposes config state (MCP servers, `.cursorrules`, approval + mode, sandbox) but its actual AI activity lives in a SQLite database we + haven't parsed yet. A thin read-only adapter is a follow-up. +- **Gemini CLI** doesn't persist context-compaction markers to disk, so + compaction detection is Claude + Codex only. +- **OpenClaw** doesn't persist tool_result content or compaction markers + to its JSONL — structural limit of what's on disk, not an adapter gap. +- **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** (by + Nous Research — the OpenClaw successor with a closed learning loop) + persists sessions to `~/.hermes/state.db` (SQLite + FTS5). The adapter + polls the DB over chokidar + 2s safety-net and emits the full + session/prompt/response/tool-call stream. Replay re-runs single turns + via `hermes chat -q -Q --max-turns 1`. + +--- + +## Features + +### Live multi-agent timeline + +Main screen. Every event your agents emit, ordered by event timestamp (not +arrival order, so backfill from different sessions merges correctly). +Columns: time · agent · type · `[project]` summary · duration · error. ``` -agentwatch launch the TUI -agentwatch doctor detect installed agents and print config paths -agentwatch --help usage +09:54:01 openclaw response [content_agent] Checked the KB… +09:52:53 claude-code response [auraqu] Commit bddc363. q now exits instantly… +09:52:48 codex shell_exec [dataset_research] ls -la · 12ms +09:52:43 claude-code tool_call [auraqu] Edit: src/ui/App.tsx · 7ms +09:51:51 gemini file_write [landing] write_file: public/llms.txt +09:51:51 claude-code tool_call [auraqu] Agent: Competitive landscape ▸ 52 child events +``` + +Rows with an anomaly fire a red `◎` prefix on the type column. + +### Event detail pane + +Press **`Enter`** on any row. Opens a full-screen pane with: + +- Metadata (time, agent, type, tool, path, cmd) +- Tokens / cost / duration (`in=6 cache_create=25508 cache_read=16827 out=353` · `$0.08 (claude-opus-4-6)` · `151ms`) +- Tool result — stdout for Bash, file content for Read/Write, search matches for Grep — with syntax highlighting inferred from the tool + file extension +- Full prompt or response text +- Extended thinking block when present +- Tool input JSON + +Scrollable with `↑↓` or `j/k`. `esc` closes. + +### Subagent drilldown + +Parent `Agent` tool_use events show `▸ 52 child events`. Press **`x`** to +scope the timeline to only that subagent's inner tool calls. `X` unscopes. +Applies to Claude Code (Task tool) and partially to OpenClaw (per-agent +delegation) and Gemini (subagent sessions). + +### Project + session navigation + +``` +P → projects grid (one workspace per row, across all agents) + ↓ enter → sessions list (grouped Today / Yesterday / 7d / Older) + ↓ enter → scoped timeline +``` + +Projects grid aggregates across agents: per-agent session counts, total +cost, last activity. `esc` walks back one level. + +### Cross-session search (`?`) + +Press **`?`** — fuzzy-substring search across every session file on disk +(`~/.claude`, `~/.codex`, `~/.gemini`). Uses ripgrep if installed, falls +back to a native scan. Enter on a hit scopes the timeline to that session. + +Different from in-buffer search: +- **`/`** — search the 500-event live buffer +- **`?`** — search every session file ever written + +### Per-session cost with cache accounting + +Naive token counters are 3–10× wrong on Claude because `cache_read` is +billed at 10% of input and `cache_creation` at 125%. agentwatch ships a +per-model rate table (Claude opus/sonnet/haiku, GPT-5 / GPT-5-mini, +Gemini 2.5 Pro/Flash) and computes true USD cost per turn. Cost shows: + +- Per-agent total in the side panel +- Per-event in the detail pane +- Per-session in the sessions list +- Aggregate in the session's token attribution view (`[t]`) + +### Per-turn token attribution (`[t]`) + +Inside a scoped session, press **`t`**. Stacked bar per turn showing: + +- `user` — the preceding prompt (tokenized with `gpt-tokenizer`) +- `memory file` — CLAUDE.md / AGENTS.md / GEMINI.md / .cursorrules / etc., read from the session's cwd +- `tool I/O` — tool_input JSON + tool_result text +- `thinking` — extended thinking block +- `input (fresh)` / `cache read` / `cache create` / `output` — exact from the model's own usage record + +### Compaction visualizer (`[C]`) + +Inside a scoped session, press **`C`**. Horizontal bar of context fill % +across turns, with `⋈` markers where the agent auto-compacted. Selected +compaction shows before / after token counts and the dropped-token delta. +Works on Claude Code (via `isCompactSummary`) and Codex (via +`event_msg/turn_truncated`). + +### Budget alarms + +`~/.agentwatch/budgets.json`: + +```json +{ "perSessionUsd": 5, "perDayUsd": 20 } +``` + +Red banner in the Header when either cap is crossed; OS notification +fires once per crossing. No kill switch — we don't control agents; we +just shout. + +### Anomaly detection + +Three detectors, all fully local, all running on the 500-event buffer: + +- **MAD z-score outliers** on cost, duration, and input tokens per agent + (`|z| > 3.5` by default — tune in `~/.agentwatch/anomaly.json`) +- **Stuck-loop detector** with periods 1–4 — catches `A-A-A-…` and + `A-B-A-B-…` "apologize and retry" loops +- Per-session rollup + OS notification on first flag + timeline `◎` marker + + `[D]` to dismiss the banner + +### User-defined notification triggers + +`~/.agentwatch/triggers.json` — live-reloaded via chokidar: + +```json +[ + { "match": "curl .* \\| (bash|sh)", "title": "pipe-to-shell", "body": "{{agent}}: {{cmd}}" }, + { "type": "file_write", "pathMatch": "^/etc/", "title": "/etc write" }, + { "thresholdUsd": 0.5, "title": "expensive turn", "body": "cost {{cost}}" } +] +``` + +Placeholders: `{{agent}} {{type}} {{cmd}} {{path}} {{tool}} {{summary}} {{cost}}`. + +### Desktop notifications + +Built-in alerts fire on sensitive events — `.env` access, `~/.ssh` / +`~/.aws` / `~/.gnupg` paths, `rm -rf`, `sudo`, `curl | sh`, tool errors, +budget breach, anomaly. Rate-limited (60s per rule key). Silent during +backfill. + +Platform dispatch: `osascript` on macOS, `notify-send` on Linux, +PowerShell `MessageBox` on Windows. Zero third-party dependencies. + +### Per-agent permission surface (`[p]`) + +Scrollable view showing: + +- **Claude Code** — allow / deny / defaultMode; flagged risks (`Bash(*)`, missing `.ssh` denies, `auto` / `bypass` modes in red) +- **Codex** — config.toml projects + trust_level; latest session's sandbox_policy, approval_policy, writable_roots, network_access, model +- **Gemini CLI** — auth type, selected model, tool allow/block lists, trusted folders +- **Cursor** — approval mode, sandbox state, MCP servers, discovered `.cursorrules` +- **OpenClaw** — default workspace + per-sub-agent (name, emoji, model, workspace) + +### Session export (`[e]`) + +From a session list or scoped timeline, press **`e`**. Writes +`./agentwatch-export/--.md` (human-readable transcript +with tool calls as fenced blocks) and `.json` (raw events). Path copied to +clipboard. + +### Syntax highlighting in the detail pane + +`cli-highlight` (tiny ANSI highlighter) applies to: +- Tool input JSON +- Tool result when the tool is Bash or the file extension is known (`.ts`, `.py`, `.rs`, `.go`, etc.) +- Fenced blocks in user/assistant text + +### Stale-session detection + +Sessions and projects idle for > 5 minutes render dimmed with a `⊘ stale` +badge. Un-greys on the next event. + +### Clipboard yank (`[y]`) + +Copies the most useful payload (tool result > full text > cmd / path / +summary). Uses `pbcopy`, `wl-copy` / `xclip` / `xsel`, or `clip`. +Confirmation flashes at the footer. + +--- + +## Keyboard reference + +Press **`?`** anytime to open this inside the TUI. + +### Navigate + +| Key | Action | +| ------------------ | ---------------------------------------------- | +| `↑ ↓` / `j k` | move selection in the timeline | +| `Enter` | open event detail pane | +| `esc` | close current view / clear selection | +| `P` | projects grid | +| `Enter` on project | sessions list for that project | +| `Enter` on session | scoped timeline for that session | +| `q` / `Ctrl-C` | quit | + +### Filter & scope + +| Key | Action | +| ---- | ------------------------------------------------------------ | +| `/` | in-buffer search (last 500 events) | +| `?` | cross-session search (every session file on disk) | +| `f` | cycle agent filter | +| `a` | toggle agent side panel | +| `x` | drill selected Agent event into its subagent run | +| `X` | unscope subagent | +| `A` | clear project filter | +| `Z` | clear all filters | + +### Actions + +| Key | Action | +| --------- | ------------------------------------------- | +| `y` | yank selected event content to clipboard | +| `e` | export current session to `.md` + `.json` | +| `space` | pause / resume live event stream | +| `c` | clear event buffer | +| `D` | dismiss the current anomaly banner | + +### Info overlays (only in a scoped session) + +| Key | Action | +| ------ | ----------------------------------------- | +| `t` | per-turn token attribution | +| `C` | context compaction visualizer | +| `p` | permissions view (works anywhere) | + +--- + +## Configuration + +Four config files, all optional. Loaded on startup; triggers reload live. + +| File | Purpose | +| -------------------------------- | -------------------------------------------------------- | +| `~/.agentwatch/triggers.json` | User-defined notification rules (live-reloaded) | +| `~/.agentwatch/budgets.json` | `perSessionUsd` / `perDayUsd` spend caps | +| `~/.agentwatch/anomaly.json` | `zScore`, `loopWindow`, `loopMinRepeats`, `minSamples` | + +Environment variables: + +| Variable | Default | Purpose | +| ------------------------------ | --------------------------- | ----------------------------------------------------- | +| `WORKSPACE_ROOT` | `~/IdeaProjects` (fallback) | Where the generic filesystem watcher looks for edits | +| `AGENTWATCH_CONTEXT_WINDOW` | `200000` | Tokens per window — used by compaction % calculation | +| `AGENTWATCH_OTLP_ENDPOINT` | unset | Enables the OTel exporter when set | +| `NO_COLOR` | unset | Standard honoring: disables ANSI colors if set | + +Workspace fallback chain (used when `WORKSPACE_ROOT` isn't set): +`~/IdeaProjects` → `~/src` → `~/code` → `~/Projects` → `~/dev` → `$HOME`. + +--- + +## What agentwatch reads + +Read-only. agentwatch writes to exactly two places: your terminal and the +clipboard (on explicit `y`) / disk (on explicit `e` to export). + +| Path | What | +| ------------------------------------------------------------ | ---------------------------------------- | +| `~/.claude/projects/**/*.jsonl` | Claude Code session transcripts | +| `~/.claude/projects/**/subagents/*.jsonl` | Claude Code Task-spawned subagents | +| `~/.claude/settings.json` | Claude permissions | +| `~/.codex/sessions/**/rollout-*.jsonl` | Codex session transcripts | +| `~/.codex/config.toml` | Codex permissions + trust levels | +| `~/.gemini/tmp/**/chats/*.json` | Gemini CLI transcripts + tool calls | +| `~/.gemini/settings.json` + `trustedFolders.json` | Gemini permissions | +| `~/.openclaw/agents/*/sessions/*.jsonl` | OpenClaw sub-agent sessions | +| `~/.openclaw/logs/config-audit.jsonl` + `openclaw.json` | OpenClaw config audit + agent roster | +| `~/.hermes/state.db` (SQLite) | Hermes Agent sessions + messages | +| `~/.cursor/{mcp.json, cli-config.json, ide_state.json}` | Cursor config state | +| Any `.cursorrules` / `.cursor/rules/*.mdc` under WORKSPACE | Cursor project rules | +| `{CLAUDE,AGENTS,GEMINI,OPENCLAW}.md` + `.windsurfrules` etc. | Per-agent memory files for token attribution | +| `~/.agentwatch/*.json` | User config (triggers / budgets / anomaly) | +| `$WORKSPACE_ROOT` tree | Filesystem change events | + +`SECURITY.md` carries the authoritative list and details of what is *not* read. + +--- + +## MCP server mode + +Run agentwatch as an MCP server so other agents can query their own +history. Install: + +```bash +claude mcp add agentwatch -- npx -y @misha_misha/agentwatch mcp +# or edit ~/.claude.json / ~/.cursor/mcp.json manually ``` -`$WORKSPACE_ROOT` overrides the detected workspace root. +Tools exposed: + +| Tool | Args | Returns | +| ------------------------- | --------------------------------- | ----------------------------------------------------- | +| `list_recent_sessions` | `limit?: 1-100` | `[{agent, sessionId, project, lastActivity, sizeBytes}]` | +| `get_session_events` | `sessionId`, `maxBytes?: 1K-10M` | Raw JSONL (tail-capped) for that session | +| `search_sessions` | `query`, `limit?: 1-50` | `[{session, agent, line}]` substring hits | +| `get_tool_usage_stats` | `sessionId?`, `limit?: 1-500` | Per-tool counts, totalDurationMs, errorCount | +| `get_session_cost` | `sessionId` | `{totalCostUsd, turns, tokens, byModel}` | + +See [`docs/features/mcp-server.md`](./docs/features/mcp-server.md). + +--- + +## OpenTelemetry exporter + +Set `AGENTWATCH_OTLP_ENDPOINT=http://localhost:4318/v1/traces` to emit +OTLP/HTTP spans for every agent event. Uses the OpenTelemetry GenAI +semantic conventions so any consumer (Jaeger, Tempo, Honeycomb, Grafana) +can interpret the data without custom dashboards. + +Attributes emitted: + +- `gen_ai.system` (anthropic | openai | google | cursor | …) +- `gen_ai.operation.name` (chat | tool_use | context_compaction | …) +- `gen_ai.request.model` / `gen_ai.response.model` +- `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` +- `gen_ai.tool.name` / `gen_ai.tool.call.id` +- `error.type` on tool errors +- `agentwatch.session.id` / `agentwatch.cost_usd` +- `agentwatch.cache_read_tokens` / `agentwatch.cache_create_tokens` / `agentwatch.cache_hit_ratio` +- `agentwatch.context.fill_pct` +- `agentwatch.risk_score` + +OTel deps are loaded dynamically only when the env var is set — zero +runtime cost when disabled. + +--- ## How it compares -| | agentwatch | claude-devtools | Unfucked | Langfuse / Phoenix | -|---|---|---|---|---| -| Runs locally only | ✓ | ✓ | ✓ | self-host possible | -| Multi-agent | ✓ Claude + Cursor + OpenClaw | Claude only | agent-agnostic (file-level) | production apps, not CLI agents | -| Per-agent attribution | ✓ | ✓ | ✗ (file-level only) | N/A | -| Permission surface view | ✓ | ✗ | ✗ | ✗ | -| Install | `npm i -g` | Homebrew / Electron app | Homebrew / Rust binary | Docker + Postgres | +| | **agentwatch** | claude-devtools | Claudex | ccflare | Langfuse / Phoenix | +| ---------------------------------- | ---------------------------------------------- | --------------------- | --------------------- | ------------------ | ---------------------------- | +| Runs locally only | ✅ | ✅ | ✅ | ✅ | self-host possible | +| Multi-agent | ✅ Claude, Codex, Gemini, Cursor (config), OpenClaw | Claude only | Claude only | Claude only | production LLM apps | +| Real token + cost with cache | ✅ | ✅ | 🟡 | ✅ (proxy-level) | ✅ | +| Per-turn token attribution | ✅ | ✅ | ❌ | ❌ | ❌ | +| Compaction visualizer | ✅ | ✅ | ❌ | ❌ | ❌ | +| **Anomaly detection** | **✅ MAD + stuck-loop** | rule-based only | ❌ | ❌ | ❌ | +| **Budget alarms w/ OS notification** | **✅** | ❌ | ❌ | ❌ | ❌ | +| **User triggers (regex/threshold)** | **✅ live-reload** | ❌ | ❌ | ❌ | ❌ | +| **OTel exporter (gen_ai.*)** | **✅** | ❌ | ❌ | ❌ | ✅ (its own format) | +| MCP server (self-query) | ✅ | ❌ | ✅ | ❌ | ❌ | +| Permission surface view | ✅ 5 agents | ❌ | ❌ | ❌ | ❌ | +| Subagent drilldown | ✅ | ✅ | ❌ | ❌ | ✅ (LangChain-specific) | +| Install | `npm i -g` | Homebrew / Electron | `npm i -g` | Bun repo | Docker + Postgres | +| UI | TUI (Ink) | Electron + standalone | Web UI | Web + TUI | Web | +| Telemetry | none | none | none | none | opt-in | + +Three moats are genuinely unique: **anomaly detection** (statistical, not +rule-based), **budget alarms**, and **OTel with gen_ai.* conventions**. + +--- + +## Limitations + +- **agentwatch is a viewer, not a daemon.** It captures events only while + the TUI is running. A background-capture daemon is planned. +- **Backfill is bounded.** On launch we read the last ~4 MB of each + active session file (roughly hundreds of events). For long gaps on + very active sessions, earliest events may fall out of the backfill + window. Keep agentwatch open in a tmux pane for zero gaps. +- **Cursor activity is config-level only.** Cursor's AI activity lives in + a SQLite database we don't parse yet. We capture config changes + + `.cursorrules` + MCP servers + `.cursor/rules/*.mdc`. Full activity + parsing is a follow-up. +- **Gemini and OpenClaw have data-structure gaps.** Gemini CLI doesn't + persist compaction markers to disk. OpenClaw doesn't persist + tool_result content or compaction markers. Not fixable from our side. +- **Windsurf, Aider, Cline** are detected but not instrumented yet. +- **macOS and Linux only.** Windows needs more chokidar + notifier + testing before we promise it. +- **tokenizer is cl100k_base (gpt-tokenizer)**, which is ~5% off for + Claude. Exact tokens for input / cache / output come from the model's + own usage record; the ~5% approximation only affects the user / + thinking / tool I/O / memory-file categories in the attribution view. + +--- ## Non-goals -- Not cloud. Not a SaaS. Not ever. -- Not an agent itself. -- Not production LLM-app tracing — [Langfuse](https://langfuse.com) owns that space. -- Not enterprise compliance — Anthropic's Compliance API covers that. -- Not orchestration. Use [Mission Control](https://github.com/MeisnerDan/mission-control) or [DevSwarm](https://devswarm.ai) for running agents in parallel. +Hard scope boundaries so agentwatch stays small and maintainable. + +- **Not cloud. Not SaaS. Not ever.** +- **Not an agent itself.** It watches agents; it doesn't take actions. +- **Not production LLM-app tracing.** [Langfuse](https://langfuse.com) owns that. +- **Not enterprise compliance.** Anthropic's Compliance API covers that. +- **Not orchestration.** Use Mission Control / Stoneforge for running agents in parallel. +- **Not memory.** Use [claude-mem](https://github.com/thedotmack/claude-mem). +- **Not governance / policy enforcement.** Use DashClaw / Castra. -## Roadmap +--- -- Codex + Gemini CLI adapters -- Deeper Cursor activity (SQLite AI-tracking DB) -- MCP proxy mode (`agentwatch wrap `) -- Permission viewer for OpenClaw + Cursor + Codex + Gemini +## Architecture -Feature requests → [GitHub issues](https://github.com/mishanefedov/agentwatch/issues). +TypeScript monorepo. Three-layer mental model: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TUI layer (ink / React) │ +│ Timeline · EventDetail · Permissions · Projects │ +│ Sessions · Tokens · Compaction · CrossSearch · Header │ +│ │ +│ MCP server (stdio — programmatic, not a UI) │ +│ list_recent_sessions · get_session_events │ +│ search_sessions · get_tool_usage_stats · get_session_cost │ +└─────────────────────────▲───────────────────────────────────┘ + │ EventSink.emit / enrich +┌─────────────────────────┴───────────────────────────────────┐ +│ Adapter layer (one per agent) │ +│ claude-code · codex · gemini · cursor · openclaw · hermes │ +│ fs-watcher (generic) │ +└─────────────────────────▲───────────────────────────────────┘ + │ files read-only +┌─────────────────────────┴───────────────────────────────────┐ +│ OS (log files, config files, clipboard, notifier) │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Adapters read files, translate raw log lines into canonical `AgentEvent`s, emit through an `EventSink`. +- `EventSink.enrich(id, patch)` lets an adapter update a previously-emitted event (e.g. when a tool_result arrives late and needs to attach duration + output to the original tool_use). +- The TUI is a pure reducer over the event buffer. Filtering, search, scope are derived views — no mutation. +- The MCP server is a peer of the TUI: it reads the same session files on demand, via its own scan (no shared in-memory state with the TUI). This is a known duplication; see Linear for the refactor ticket. + +See `src/schema.ts` for the canonical event shape. + +--- ## Development @@ -85,11 +658,44 @@ Feature requests → [GitHub issues](https://github.com/mishanefedov/agentwatch/ git clone https://github.com/mishanefedov/agentwatch.git cd agentwatch npm install -npm run dev +npm run dev # launch the TUI directly from source (tsx) +npm test # vitest — 97 tests +npm run typecheck # strict TypeScript +npm run build # tsup → dist/ ``` -Run tests with `npm test`. Typecheck with `npm run typecheck`. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the contribution workflow. + +### Docs + +- **[`docs/features/`](./docs/features/)** — feature specs (scope, inputs, outputs, failure modes). Being extended feature-by-feature. +- **[`docs/testing/`](./docs/testing/)** — manual test procedures + a pre-release walkthrough. +- **[`docs/use-cases/`](./docs/use-cases/)** — multi-agent triage, cost-overrun investigation, security audit, stuck-loop detection, subagent post-mortem, .env leak alert. + +--- + +## Security + +Local-first is a hard invariant. + +- **Zero network calls** unless you explicitly set `AGENTWATCH_OTLP_ENDPOINT` (to a host *you* chose, OTel output only). +- **Zero telemetry.** Not opt-in, not opt-out — simply not there. +- **All files read-only** except the clipboard (on `y`) and `./agentwatch-export/` (on `e`). +- Every path agentwatch reads is documented in [SECURITY.md](./SECURITY.md). + +Report vulnerabilities privately: `misha@auraqu.com` or via a +[Security Advisory](https://github.com/mishanefedov/agentwatch/security/advisories/new). + +--- ## License MIT © Misha Nefedov. See [LICENSE](./LICENSE). + +--- + +
+ +If agentwatch saves you a debugging hour, a ⭐ on the repo makes the effort worth it. + +
diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8408430 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,427 @@ +# agentwatch — roadmap + +*Last updated: 2026-05-01. This doc is the navigation chart, not the +backlog. The backlog lives in Linear (`agentwatch` project, AUR-*).* + +--- + +## TL;DR + +We are at v0.0.4 with a credible TUI + web dashboard, six native agent +adapters, and three genuinely unique capabilities (multi-agent permission +surface, statistical anomaly detection, inter-agent call graph). The +category around us has consolidated faster than expected — three tools +between 1k–5k stars now ship persistent storage, headless capture, and +activity classification, none of which we have. + +**Recommended path:** close the table-stakes gaps in the next 4–6 weeks +(daemon + SQLite + activity classification + git correlation + Claude +hooks), then publicly launch on the multi-agent + permission + anomaly +moat. Treat 0.1 as a re-introduction, not the launch. + +--- + +## 1. Market landscape (May 2026) + +The "watch your local agents" category has split into four lanes. Lanes +2 and 3 are where agentwatch competes; lanes 1 and 4 are *not* our +competition and we should stop comparing to them. + +### Lane 1 — Cloud LLM observability *(not us)* + +Langfuse, Arize Phoenix, Helicone, LangSmith, Datadog LLM Observability, +Braintrust, Maxim. All converged on the OpenTelemetry GenAI semantic +conventions standardized in March 2026. Built for production LLM apps, +not local coding agents. Charging $X/seat/month. Different problem. + +### Lane 2 — Local cost trackers *(direct competition)* + +| Tool | Stars | Agents | Storage | Differentiator | +|---|---:|---|---|---| +| **ccusage** | ~4.8k | Claude only | local cache | Fastest CLI, leanest, cited as "what most people install first" | +| **CodeBurn** | ~4.7k | 16 (Claude / Codex / Cursor / Gemini / Copilot / OpenCode / OpenClaw / Pi / Droid / Roo / Kilo / Qwen / Kiro / cursor-agent / Claude Desktop / OMP) | local | Activity classification (13 categories), one-shot rate, optimize-mode, model compare, plan tracking. Show HN 112 pts. | +| **AgentsView** (wesm) | 873 | 16 (Claude / Codex / Copilot CLI / Gemini / OpenCode / OpenHands / Cursor / Amp / iFlow / VSCode Copilot / Pi / OpenClaw / Kimi / Kiro CLI / Kiro IDE / Cortex Code) | **local SQLite + FTS** | "100x faster ccusage replacement," PostgreSQL sync for teams, desktop app, FTS on session messages. v0.26.1 on 2026-05-01. | +| **Claude Usage Tracker** | — | 9+ auto-detected | local | Heatmaps, monthly projections, scoped to cost. | +| **agentwatch (us)** | (pre-launch) | 6 | **in-memory + 4MB rolling backfill** | Multi-agent permission surface, anomaly detection, budget alarms, OTel exporter, MCP server mode, inter-agent call graph | + +### Lane 3 — Hooks-based real-time monitors *(adjacent / partial competition)* + +| Tool | Stars | Mechanism | +|---|---:|---| +| **disler/claude-code-hooks-multi-agent-observability** | 1.4k | Real-time WebSocket via Claude Code hook events → Vue dashboard. Captures all 12 hook event types. Tracks subagent/parent relationships. | +| **simple10/agents-observe** | smaller | Same idea, lighter | +| **nexus-labs-automation/agent-observability** | smaller | Plugin form | + +These tools use Claude's *native* hooks API (`PreToolUse`, `PostToolUse`, +`SessionStart`, etc.) instead of tailing JSONL files. Trade-off: faster +and more reliable on Claude, but Claude-specific. + +### Lane 4 — Provider-native + drop-in OTEL wrappers *(table stakes leakage)* + +- **Claude Code `/cost`** (built-in since v2.1.92) — per-model + breakdown, cache hit rate, rate-limit utilization. Free with Claude. +- **Claude Agent SDK observability** — built-in OpenTelemetry traces, + metrics, log events out to OTLP. Anthropic's recommended path. +- **claude_telemetry / `claudia`** — drop-in CLI wrapper, ships traces + to Logfire / Sentry / Honeycomb / Datadog. ~10 lines of config. + +The signal here: Anthropic is putting basic cost tracking in-product. +"Show me my Claude spend" is no longer a moat. The moat is now +**everything `/cost` and ccusage do not do**: cross-agent, cross-tool, +permission, anomaly, control plane. + +### What the HN comments (Show HN: CodeBurn, 112 pts) most asked for + +1. **Parallel session tracking** — *"which of my 4 running sessions is + burning the most tokens right now?"* +2. **Cost-optimization suggestions** — proactive *"you wasted X on + re-reading the same file"* +3. **Windows support** +4. **Cursor-Agent CLI support** + +We already ship 1, partially. We do not ship 2, 3, or 4. + +--- + +## 2. Where agentwatch stands + +### Genuinely unique (defend these) + +| Capability | Status | Who else has it | +|---|---|---| +| Multi-agent permission surface (Claude / Codex / Gemini / Cursor / OpenClaw configs in one view) | shipped | nobody | +| Statistical anomaly detection (MAD z-score + stuck-loop period 1–4) | shipped | nobody | +| User-defined regex / threshold triggers (live-reloaded) | shipped | nobody | +| Per-session + per-day budget alarms with OS notifications | shipped | nobody | +| Inter-agent call graph (parent_span_id chain-linking) | shipped | nobody | +| MCP server mode (agents query their own history) | shipped | only CodeBurn ships an MCP, but read-only stats | +| OpenTelemetry exporter with `gen_ai.*` conventions | shipped | claude_telemetry (Claude only) | +| Hermes Agent adapter | shipped | nobody else covers Hermes | +| TUI + web in one process (single port, one keypress to open) | shipped | CodeBurn is TUI only; AgentsView has a separate desktop app | + +### Table-stakes gaps (close these) + +| Gap | Who has it | Why it matters | +|---|---|---| +| **Headless background daemon** (continuous capture without TUI open) | AgentsView, disler, claude_telemetry | Our README explicitly admits this. Competitors run 24/7. | +| **Persistent indexed storage (SQLite + FTS)** | AgentsView, ccusage cache | We have 4 MB rolling buffer. Loses data, can't do WoW trends. | +| **Activity classification per turn** (coding / debugging / exploration / planning) | CodeBurn (13 categories) | The #1 thing HN loved about CodeBurn. *"56% of my spend was on conversation turns with no tool usage"* — that line went viral. | +| **Git-correlation yield analysis** (sessions ↔ commits) | CodeBurn | Answers "is this spend producing code?" | +| **Side-by-side model performance compare** | CodeBurn | Plays to "should I be on Sonnet or Opus for this kind of work?" | +| **Subscription / plan tracking** (Claude Pro 5h limits, Cursor Pro caps) | CodeBurn, Claude Usage Tracker | High-perceived-value, low effort. | +| **Claude Code native hooks** (`PreToolUse`, `PostToolUse` etc.) | disler, nexus-labs | More reliable than JSONL tailing for Claude specifically. Anthropic's recommended path. | +| **Windows support** | CodeBurn, AgentsView | We've explicitly excluded it. Cuts ~30% of the addressable market. | +| **Unscoped npm name** | every competitor | `@misha_misha/agentwatch` reads as a personal project. | + +### Distribution gap + +We are at v0.0.4 with a published-but-buggy npm package, no Show HN, no +README hero metric ("X stars" — we have none yet), and the GitHub name +`agentwatch` collides with cyberark/agentwatch (Python SDK observability +for LangGraph/Autogen — different product, different audience, but +muddies search). Every direct competitor has an order-of-magnitude head +start in stars and a clearer name. + +--- + +## 3. The fork in the road + +Three honest directions. We pick one. + +### Direction A — *Catch up + lean on uniques.* Conservative. + +**Story:** "ccusage and CodeBurn are great if you only use one agent. If +you run Claude + Codex + Gemini + Cursor + OpenClaw on the same machine, +you need a tool that knows about all of them, watches their permissions, +catches their loops, and warns you when one is melting your card." + +**Investment:** 4–6 weekends. + +- Close 5 of the 9 table-stakes gaps (daemon, SQLite, activity + classification, git correlation, hooks). +- Punt Windows + plan tracking + side-by-side compare to v0.2. +- Ship 0.1 with a Show HN. + +**Ceiling:** plausibly 1k stars in 6 months, 5k–10k in 12 months if the +multi-agent angle resonates. Becomes a credible Lane-2 player. No +revenue. Pure tool / credibility artifact. + +**Risk:** low. We are technically ahead on the unique features and +behind on the table stakes. Closing the gap is bounded engineering. + +--- + +### Direction B — *Leapfrog: hooks-native, multi-agent, control-plane.* Ambitious. + +**Story:** "Anthropic gave Claude Code a hooks API in 2026. Use it. +Combine native hooks for Claude with file-tailing for everything else, +add the missing layer everyone's faking with regex: real +*intervention.* Block destructive Bash before it runs. Cap budgets in +real time. Approve sensitive edits before they hit disk. agentwatch is +the local control plane the multi-agent stack does not have." + +**Investment:** 8–12 weekends. + +- Everything in Direction A, plus: +- Native hooks integration (Claude blocking via `PreToolUse`). +- Real-time approval forwarding (existing OpenClaw approval pattern, + generalized). +- Policy editor that *enforces*, not just reports. +- Replay-as-validation (run the same prompt against three models, pick + the cheapest that passes). + +**Ceiling:** materially higher than A. The control-plane category is +mostly empty (Castra / DashClaw exist but are pre-1k-star). If we +land first with a credible product, we own the category. + +**Risk:** medium-high. Breaks the README's "not an agent itself, not +governance, not orchestration" non-goal. Puts us in conflict with +Anthropic's own Compliance API. Bigger surface, more bugs. + +--- + +### Direction C — *Pure cost analytics, sharper than CodeBurn.* Conservative-narrow. + +**Story:** drop the TUI / web / permissions / anomaly / OTel / +multi-agent framing. Compete head-on with ccusage and CodeBurn on the +single axis of *cost analytics done well.* + +**Investment:** 2–3 weekends. + +- Remove most of the v0.0.3–0.0.4 surface area. +- Sharpen on activity classification + git yield + model compare. + +**Ceiling:** capped. ccusage and CodeBurn already exist and have +4–5k-star moats. We'd be third in a race that's already half-run. + +**Verdict:** rejected. Throws away our actual differentiation. + +--- + +## 4. Recommended path: A first, evaluate B at v0.2 + +Direction A is the obvious near-term move because: + +1. The table-stakes gaps are bounded engineering with clear acceptance + criteria. No new product invention. +2. Closing them lets us re-enter the category with a defensible pitch. +3. Direction B is best evaluated *after* we have user signal. Picking + it now without users is choosing complexity blind. + +Concretely: ship 0.1 with daemon + storage + classification + git + +hooks; do the Show HN; collect 90 days of feedback; *then* decide if +Direction B's control-plane bet is worth the non-goal break. + +If the Show HN lands at >150 points and we hit 500 stars in week one, +Direction B becomes the obvious v0.3 bet. If it lands quietly, we stay +in Direction A and harden the multi-agent + anomaly story. + +--- + +## 5. Phased plan + +### v0.0.5 — *cleanup release* (this week) + +Ship the Apr 27 robustness fixes that are sitting in `[Unreleased]`. + +- Externalized pricing via `~/.agentwatch/pricing.json` (AUR-216, done) +- OpenClaw toolResult pairing (AUR-217, done) +- Surface unparseable JSONL lines (AUR-228, done) +- Compaction docs for Gemini/OpenClaw structural limit (AUR-214, done) +- Defensive directives initializer (AUR-242, done) +- Cron timeout wrappers (AUR-241, done) +- Bump version, write CHANGELOG, publish. + +**Acceptance:** `npm i -g @misha_misha/agentwatch@0.0.5` works on a +fresh machine, doctor passes, no buggy label. + +--- + +### v0.1 — *the launch release* (next 4–6 weeks) + +Goal: be a credible Lane-2 entrant with a defensible "multi-agent + +permission + anomaly" pitch. Ready for Show HN. + +**Must have (all five):** + +1. **Background daemon** (`agentwatch daemon start | stop | status`). + Continuous capture, runs as a launchd / systemd service, survives + TUI close. Pipes to: +2. **SQLite event store** (`~/.agentwatch/events.db`) with FTS5 on + prompt/response/tool-result text. Replaces the 4 MB rolling buffer. + Backfill old JSONLs into it on first run. +3. **Activity classification per turn** (≥10 categories: coding / + debugging / exploration / planning / refactor / test / docs / + chat / config / review). Ships per-session and per-week + breakdowns in the web UI. +4. **Git-correlation yield view** — pair sessions with the commits + they produced, show $/commit and "spend without commit" sessions. +5. **Claude Code native hooks adapter** — alongside JSONL tailing. + Real-time `PreToolUse` / `PostToolUse` events, no polling lag. + Falls back gracefully if hooks aren't configured. + +**Should have:** + +6. Unscoped npm name. Pick one of: `agentwatch-cli`, `agw`, `agentlens`, + `cdwatch`. Verify GitHub + npm + domain availability before release. +7. WoW trends view (cache-hit ratio drift, AUR-215). Already in + progress. Daemon + SQLite makes this trivial. +8. Aider + Cline + Continue + Windsurf adapters (file-tail; same + pattern as Codex / Gemini). +9. Cursor SQLite adapter (closes the only ⓟ at half-coverage in our + matrix). + +**Could have (defer to v0.2 if compressing):** + +10. Plan tracking (Claude Pro 5h limit, Cursor Pro caps). +11. Side-by-side model compare view. +12. Windows support. + +**Acceptance gates:** + +- `agentwatch daemon` runs for 7 days continuously on the maintainer's + machine without crash or memory leak. +- SQLite db is < 200 MB after 30 days of typical use. +- Activity classifier hits ≥75% agreement with hand-labelled ground + truth on a 200-turn validation set. +- Show HN draft + screenshots ready (lean on call-graph + permission + + multi-agent timeline GIFs, not generic cost charts). +- README rewritten so "vs CodeBurn / vs AgentsView / vs ccusage" sits + at the top and is honest. + +**Distribution:** + +- Show HN +- r/ClaudeAI cross-post (their FAQ links to ccusage; aim for similar + treatment) +- r/LocalLLaMA cross-post +- Tweet thread with the call-graph demo as the lead asset + +--- + +### v0.2 — *harden + reach* (~Q3 2026) + +Goal: convert v0.1 momentum into a 3k-star tool. + +- **Windows support** (chokidar + notifier testing; don't promise until + it works on a clean Win11 box). +- **Side-by-side model compare** (replay a turn across 3 models, show + cost / time / output quality). +- **Plan tracking** (Claude Pro 5h, Cursor Pro caps, OpenAI tier limits). +- **Cost-optimization suggestions** — the *"56% of your spend is + conversation"* thing CodeBurn went viral on. We have the data; just + add the surfacing. +- **`agentwatch open `** — deep-link from the OS notifier into + the relevant event detail. +- **Stable schema 1.0** — `src/schema.ts` becomes a public contract. +- **Plugin SDK** — `agentwatch.config.ts` lets users register custom + classifiers, triggers, exporters without forking. +- **Cross-machine sync** (opt-in; user-supplied Postgres or LiteFS + endpoint; mirrors AgentsView's PostgreSQL sync feature). Keeps the + "no cloud" invariant by being BYO endpoint, not a service. + +--- + +### v0.3 — *evaluate Direction B* (~Q4 2026) + +Decision point. Based on v0.1 + v0.2 signal: + +**If the multi-agent + permission story is resonating** (≥3k stars, +recurring "we deployed this team-wide" feedback): start the control +plane. + +- Native Claude hooks blocking (`PreToolUse` returning `decision: + "block"` for matched policies) +- Approval forwarding (Telegram / Slack / desktop) +- Policy DSL (built on the existing trigger schema) +- Replay-as-validation gate + +**If it isn't resonating:** drop deeper into single-purpose excellence. +Ship the missing classifier / yield / compare / Windows / plan +features harder. Stay in Lane 2 and try to be the best Lane-2 player. + +--- + +### v1.0 — *stable* (~end of 2026) + +Whatever direction we picked at v0.3, v1.0 stabilizes the schema, locks +the CLI contract, and earns SemVer guarantees. v1.0 is not a feature +release. + +--- + +## 6. Rejected directions (so we don't drift) + +- **Cloud / SaaS / hosted dashboard.** Hard non-goal. Local-first is + the trust premise. Anyone who wants cloud goes to Langfuse. +- **Production LLM-app tracing.** Langfuse / Phoenix / LangSmith own + this. We are coding-agent specific. +- **Becoming an agent ourselves.** Direction B's control-plane bet is + the closest we'd get; even there, we *enforce policy*, we don't + *take actions*. +- **Compliance / governance for enterprise.** Anthropic's Compliance + API exists. We don't have the sales motion to compete. +- **Memory / context layer.** claude-mem and friends own that. +- **Orchestration / parallel agent dispatch.** Mission Control, + Stoneforge, Cursor's Manager Surface own that. +- **An LLM evaluation harness.** Promptfoo / Langfuse / Braintrust own + that. Replay is a debugging tool, not an eval suite. + +--- + +## 7. Success metrics + +We track three numbers, monthly: + +| Metric | v0.1 target (90d) | v0.2 target (180d) | v1.0 target (360d) | +|---|---|---|---| +| GitHub stars | 500 | 3,000 | 10,000 | +| Weekly active installs (telemetry-free, inferred from npm download trend + GH traffic) | 200 | 1,500 | 5,000 | +| Daemons running > 7 days continuous (self-reported in survey at v0.2 release) | n/a | 100 | 800 | + +Stars are vanity but they're the legible signal in this category; +ccusage / CodeBurn / AgentsView all sit on stars as their primary +proof. Weekly active is the truer signal once we have it. + +If at 90d we are < 200 stars and Show HN < 80 points, the recommended +path was wrong; revisit Section 3 fork. + +--- + +## 8. Operating principles + +These don't change between v0.1 and v1.0: + +- **Local-first.** Zero outbound network unless the user sets + `AGENTWATCH_OTLP_ENDPOINT` to their own collector. +- **Read-only by default.** Disk is touched only on explicit `e` + (export) and clipboard on explicit `y`. +- **No telemetry.** Not opt-in, not opt-out — not present. +- **MIT license.** +- **macOS + Linux first.** Windows is a v0.2 ambition, not a blocker + for v0.1. +- **Multi-agent first.** If a feature only helps Claude users, we ship + it only after the equivalent works for at least two other agents + *or* there is no equivalent for the others (e.g. Claude hooks). + +--- + +## 9. Open questions + +Resolve before v0.1 ships: + +- **Name.** Stay with `@misha_misha/agentwatch`, or rebrand? CyberArk + collision is a real friction. +- **Daemon transport.** Local UDS, localhost HTTP, or just shared + SQLite + advisory locks? Affects how the TUI / web / MCP-server + three-way split coexists. +- **Activity classifier.** Heuristic-only (cheap, deterministic, ships + fast) or use a small local model (better accuracy, dependency)? +- **Hooks fallback.** If the user hasn't configured Claude hooks, + silently fall back to JSONL tailing, or prompt them to install via + `agentwatch hooks install`? + +--- + +*Backlog detail lives in Linear (project: agentwatch). This doc covers +direction; tickets cover execution.* diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b521e8a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +agentwatch is a local observability tool. It reads files from your home +directory (Claude Code logs, OpenClaw workspaces, Cursor config, workspace +filesystem). It never sends data over the network. + +If you find a vulnerability, please report it privately before opening a +public issue. + +## Reporting + +- **Email:** `misha@auraqu.com` +- **GitHub:** open a [Security Advisory](https://github.com/mishanefedov/agentwatch/security/advisories/new) (private) + +Please include: + +- Version affected (output of `agentwatch --version` or package version) +- OS + Node version +- Minimal reproduction steps +- Observed impact + +I'll respond within 7 days. + +## Scope + +**In scope:** + +- Arbitrary file read outside the documented watched paths +- Exfiltration of file contents or metadata to the network +- Command injection through watched file names or log contents +- Prototype pollution or similar JavaScript-ecosystem vulns in the parser +- Denial of service against the running TUI from crafted log lines +- Supply-chain issues in the npm package + +**Out of scope:** + +- Social-engineering the maintainer +- Bugs in Claude Code / Cursor / OpenClaw themselves +- Vulnerabilities in the terminal emulator, OS clipboard tools, notification + daemons, or other OS-provided infrastructure agentwatch shells out to + +## What agentwatch reads + +Full list documented in the README, but for quick reference: + +- `~/.claude/projects/**/*.jsonl` (Claude Code session transcripts + subagents) +- `~/.claude/settings.json` (Claude permissions) +- `~/.openclaw/agents/*/sessions/*.jsonl` (OpenClaw sub-agent sessions) +- `~/.openclaw/logs/config-audit.jsonl` (OpenClaw config audit trail) +- `~/.openclaw/openclaw.json` (OpenClaw agent roster) +- `~/.cursor/mcp.json`, `cli-config.json`, `ide_state.json` (Cursor state) +- Project-level `.cursorrules` files under `$WORKSPACE_ROOT` +- The `$WORKSPACE_ROOT` tree (defaults to `~/IdeaProjects`) for file-change + events + +agentwatch **writes** only to the terminal and to the system clipboard (on +explicit `y` key press). No files are created or modified outside of its own +repo during development. + +## What agentwatch does NOT do + +- No outbound HTTP / DNS / network calls (verify with `lsof -i -p $(pgrep -n agentwatch)`) +- No telemetry. Not opt-in, not opt-out — it's just absent. +- No account, no cloud, no sign-in. +- No data leaves your machine. diff --git a/bin/agentwatch.js b/bin/agentwatch.js old mode 100644 new mode 100755 diff --git a/docs/demo-mock/README.md b/docs/demo-mock/README.md new file mode 100644 index 0000000..f673583 --- /dev/null +++ b/docs/demo-mock/README.md @@ -0,0 +1,100 @@ +# Demo mock data + +`setup.py` builds a synthetic `HOME` at `/tmp/agentwatch-demo/` with fake +Claude Code + Codex + Gemini CLI + OpenClaw session files. The narrative +is a user invoking a `/council` command that asks whether to ship 0.0.3; +Claude Code orchestrates, spawns Codex and Gemini as sub-tasks, and +OpenClaw runs an unrelated research task in parallel. + +Nothing from your real `~/.claude`, `~/.codex`, `~/.gemini`, `~/.openclaw` +is read or touched. Record the demo GIF against this mock HOME and your +actual session history stays private. + +## 1. Build the mock data + +```bash +python3 docs/demo-mock/setup.py +``` + +This wipes and recreates `/tmp/agentwatch-demo/` from scratch. Timestamps +are anchored to "30 minutes ago" so the timeline shows up as Today. Re-run +the script any time you want a fresh state. + +## 2. Smoke-check that agentwatch sees the mock agents + +```bash +HOME=/tmp/agentwatch-demo \ + WORKSPACE_ROOT=/tmp/agentwatch-demo/workspace \ + node bin/agentwatch.js doctor +``` + +You should see Claude Code, Codex, Gemini CLI, OpenClaw all marked +`● installed (events captured)`. + +## 3. Record the cast + +```bash +brew install asciinema agg # one-time +``` + +```bash +asciinema rec docs/demo.cast \ + --cols 110 --rows 34 \ + --title "agentwatch — one pane for every local AI agent" \ + --idle-time-limit 1.5 +``` + +Inside the recording shell: + +```bash +HOME=/tmp/agentwatch-demo \ + WORKSPACE_ROOT=/tmp/agentwatch-demo/workspace \ + node bin/agentwatch.js +``` + +### Demo choreography (~75 seconds) + +| t | action | shows | +| ---- | ------------------------------- | ---------------------------------------------- | +| 0s | launch agentwatch | multi-agent timeline (Claude + Codex + Gemini + OpenClaw) +| 3s | `P` | projects grid aggregated across agents +| 6s | `Enter` on workspace | sessions list grouped by date +| 10s | `Enter` on the Claude session | scoped timeline of the /council turn +| 15s | `t` | per-turn token attribution, stacked bar w/ memoryFile +| 22s | `esc` then `C` | compaction visualizer (empty in mock, prints structure) +| 28s | `esc` then `p` | permissions view: Claude + Codex + Gemini + OpenClaw +| 36s | `esc` then `/` | unified search overlay, live mode +| 40s | type `rm -rf` `Enter` | live substring match +| 45s | `Tab` (to cross-session) | cross-session hits +| 50s | `Tab` (to semantic) | first-run consent modal (don't confirm — it's mock data) +| 55s | `esc` `esc` | back to main timeline +| 60s | `f` | cycle agent filter (fun little polish moment) +| 68s | `?` | help overlay +| 73s | `q` | clean exit + +Ctrl-D exits asciinema. + +## 4. Convert to GIF + +```bash +agg docs/demo.cast docs/demo.gif \ + --font-size 14 \ + --theme asciinema \ + --speed 1.4 +``` + +Optional shrink: + +```bash +brew install gifsicle +gifsicle -O3 --colors 128 docs/demo.gif -o docs/demo.gif +du -h docs/demo.gif # aim ≤ 3 MB +``` + +## 5. Clean up + +```bash +rm -rf /tmp/agentwatch-demo +``` + +Re-running `setup.py` does the same thing (it wipes before rebuilding). diff --git a/docs/demo-mock/setup.py b/docs/demo-mock/setup.py new file mode 100755 index 0000000..48dfc0a --- /dev/null +++ b/docs/demo-mock/setup.py @@ -0,0 +1,788 @@ +#!/usr/bin/env python3 +""" +Build a mock HOME at /tmp/agentwatch-demo/ with fake session files for +Claude Code, Codex, Gemini CLI, and OpenClaw. The narrative is a user +invoking a /council slash-command that asks whether to ship 0.0.3; +Claude Code orchestrates, spawns Codex and Gemini as subagents, and +OpenClaw runs an unrelated research task in parallel. + +Usage: + python3 docs/demo-mock/setup.py + HOME=/tmp/agentwatch-demo WORKSPACE_ROOT=/tmp/agentwatch-demo/workspace \ + node bin/agentwatch.js + +All timestamps are anchored to "now - 30 minutes" so the timeline +shows up as Today. Regenerate by re-running the script. +""" + +from __future__ import annotations + +import json +import os +import shutil +import time +from pathlib import Path + +ROOT = Path("/tmp/agentwatch-demo") +WORKSPACE = ROOT / "workspace" +NOW_MS = int(time.time() * 1000) - 30 * 60_000 # 30 min ago + + +def iso(offset_ms: int) -> str: + """ISO timestamp `offset_ms` after the demo's start.""" + ms = NOW_MS + offset_ms + return ( + time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(ms / 1000)) + + f".{ms % 1000:03d}Z" + ) + + +def unix(offset_ms: int) -> int: + return (NOW_MS + offset_ms) // 1000 + + +def write_jsonl(path: Path, lines: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + for line in lines: + f.write(json.dumps(line) + "\n") + + +def write_json(path: Path, doc: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc, indent=2)) + + +def reset() -> None: + if ROOT.exists(): + shutil.rmtree(ROOT) + ROOT.mkdir(parents=True) + WORKSPACE.mkdir() + # seed a CLAUDE.md so the token-attribution view has a non-zero + # "memory file" category to render. + (WORKSPACE / "CLAUDE.md").write_text( + "# agentwatch demo workspace\n\n" + "- Prefer short, direct responses.\n" + "- Use Bash for quick shell ops.\n" + "- Ship 0.0.3 when the council signs off.\n" + ) + # sibling memory files for the other agents, so the per-agent memoryFile + # tokens light up when you press [t] on their sessions. + (WORKSPACE / "AGENTS.md").write_text( + "# codex memory\n\n- Codex does the adversarial review.\n" + ) + (WORKSPACE / "GEMINI.md").write_text( + "# gemini memory\n\n- Gemini does the independent strategic review.\n" + ) + + +# ─── Claude Code ────────────────────────────────────────────────────────── + +CLAUDE_SESSION_ID = "demo-claude-0001" +CLAUDE_PROJECT_DIR = "-tmp-agentwatch-demo-workspace" + + +def claude_session() -> None: + lines: list[dict] = [] + + def msg_user(content, ts_ms): + return { + "type": "user", + "timestamp": iso(ts_ms), + "message": {"role": "user", "content": content}, + "sessionId": CLAUDE_SESSION_ID, + "cwd": str(WORKSPACE), + } + + def msg_assistant(content, usage, cost, model, ts_ms): + return { + "type": "assistant", + "timestamp": iso(ts_ms), + "message": { + "role": "assistant", + "content": content, + "usage": usage, + "model": model, + }, + "sessionId": CLAUDE_SESSION_ID, + "cwd": str(WORKSPACE), + } + + # Turn 1: /council invocation + lines.append(msg_user("/council should we ship 0.0.3 now?", 0)) + lines.append( + msg_assistant( + [ + { + "type": "text", + "text": ( + "Framing the council: the question is whether to " + "publish 0.0.3 given M5/M6/M7 are in. I'll dispatch " + "Codex and Gemini in parallel for blinded opinions." + ), + } + ], + { + "input_tokens": 420, + "cache_creation_input_tokens": 1_180, + "cache_read_input_tokens": 0, + "output_tokens": 85, + }, + 0.0053, + "claude-opus-4-6", + 2_000, + ) + ) + + # Turn 2: tool_use #1 — codex exec + lines.append( + msg_assistant( + [ + { + "type": "tool_use", + "id": "toolu_01cx", + "name": "Bash", + "input": { + "command": "codex exec 'review ship 0.0.3 decision'", + "description": "Ask Codex for an independent opinion", + }, + } + ], + { + "input_tokens": 15, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1_265, + "output_tokens": 42, + }, + 0.0014, + "claude-opus-4-6", + 3_500, + ) + ) + lines.append( + { + "type": "user", + "timestamp": iso(7_200), + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01cx", + "content": ( + "RECOMMENDATION: No, not this minute. Release " + "surface has 0.0.2 version strings in 3 places " + "and ? still toggles help in docs. " + "30-min fix, then ship.\nCONFIDENCE: Medium.\n" + ), + } + ], + }, + "sessionId": CLAUDE_SESSION_ID, + "cwd": str(WORKSPACE), + } + ) + + # Turn 3: tool_use #2 — gemini -p (ran in parallel with codex) + lines.append( + msg_assistant( + [ + { + "type": "tool_use", + "id": "toolu_02gem", + "name": "Bash", + "input": { + "command": "gemini -p 'review ship 0.0.3 decision'", + "description": "Ask Gemini for a strategic read", + }, + } + ], + { + "input_tokens": 22, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1_382, + "output_tokens": 47, + }, + 0.0017, + "claude-opus-4-6", + 4_100, + ) + ) + lines.append( + { + "type": "user", + "timestamp": iso(9_400), + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_02gem", + "content": ( + "RECOMMENDATION: YES, SHIP IMMEDIATELY. " + "0.0.2 is marked buggy; every day delayed is a " + "day users hit known issues. CONFIDENCE: High.\n" + ), + } + ], + }, + "sessionId": CLAUDE_SESSION_ID, + "cwd": str(WORKSPACE), + } + ) + + # Turn 4: synthesis + lines.append( + msg_assistant( + [ + { + "type": "text", + "text": ( + "Codex found real bugs by code inspection; Gemini " + "missed them. Verdict: NO ship in 5 min, YES ship " + "today after a ~30 min release-candidate pass. " + "Bump every version string, fix the ?/help " + "collision, regenerate CHANGELOG, then publish." + ), + }, + { + "type": "thinking", + "thinking": ( + "Gemini's argument 'ship now, 0.0.2 is broken' is " + "correct in spirit but wrong in tactic for a 0-star " + "repo whose pitch is credibility. Codex's specific " + "bugs are verifiable facts, not opinions." + ), + }, + ], + { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2_810, + "output_tokens": 212, + }, + 0.0048, + "claude-opus-4-6", + 11_200, + ) + ) + + # Turn 5: user says "go", assistant runs npm publish + lines.append(msg_user("alright, ship it", 20_000)) + lines.append( + msg_assistant( + [ + { + "type": "tool_use", + "id": "toolu_03pub", + "name": "Bash", + "input": { + "command": "npm run typecheck && npm run build && npm publish", + "description": "Run the publish gate", + }, + } + ], + { + "input_tokens": 24, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3_022, + "output_tokens": 38, + }, + 0.0011, + "claude-opus-4-6", + 22_000, + ) + ) + lines.append( + { + "type": "user", + "timestamp": iso(27_500), + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_03pub", + "content": "+ @misha_misha/agentwatch@0.0.3\n", + } + ], + }, + "sessionId": CLAUDE_SESSION_ID, + "cwd": str(WORKSPACE), + } + ) + lines.append( + msg_assistant( + [ + { + "type": "text", + "text": "Published 0.0.3. Tag + GH release next.", + } + ], + { + "input_tokens": 12, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3_100, + "output_tokens": 18, + }, + 0.0008, + "claude-opus-4-6", + 28_100, + ) + ) + + path = ( + ROOT + / ".claude" + / "projects" + / CLAUDE_PROJECT_DIR + / f"{CLAUDE_SESSION_ID}.jsonl" + ) + write_jsonl(path, lines) + + # Also seed a minimal Claude settings.json so the permissions view + # has something to render. + claude_settings = { + "permissions": { + "allow": ["Bash(git *)", "Read(**)"], + "deny": ["Bash(rm -rf /**)"], + "defaultMode": "ask", + } + } + write_json(ROOT / ".claude" / "settings.json", claude_settings) + + +# ─── Codex ──────────────────────────────────────────────────────────────── + + +def codex_session() -> None: + session_id = "demo-codex-0001" + ts = time.gmtime(NOW_MS / 1000) + rollout_path = ( + ROOT + / ".codex" + / "sessions" + / time.strftime("%Y", ts) + / time.strftime("%m", ts) + / time.strftime("%d", ts) + / f"rollout-{time.strftime('%Y-%m-%dT%H-%M-%S', ts)}-{session_id}.jsonl" + ) + + lines: list[dict] = [] + lines.append( + { + "timestamp": iso(0), + "type": "session_meta", + "payload": { + "id": session_id, + "timestamp": iso(0), + "cwd": str(WORKSPACE), + "cli_version": "0.120.0", + "source": "cli", + "model_provider": "openai", + "model": "gpt-5", + }, + } + ) + lines.append( + { + "timestamp": iso(50), + "type": "turn_context", + "payload": { + "model": "gpt-5", + "cwd": str(WORKSPACE), + "approval_policy": "on-request", + "sandbox_policy": { + "type": "workspace-write", + "writable_roots": [str(WORKSPACE)], + "network_access": False, + }, + }, + } + ) + lines.append( + { + "timestamp": iso(400), + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "review ship 0.0.3 decision"}], + }, + } + ) + lines.append( + { + "timestamp": iso(1_600), + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "call_id": "call_cx1", + "arguments": json.dumps( + {"cmd": "grep -n version package.json", "workdir": str(WORKSPACE)} + ), + }, + } + ) + lines.append( + { + "timestamp": iso(1_840), + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_cx1", + "output": '3: "version": "0.0.2",', + }, + } + ) + lines.append( + { + "timestamp": iso(2_400), + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "call_id": "call_cx2", + "arguments": json.dumps( + { + "cmd": "grep -n '0.0.2' src/mcp/server.ts src/util/otel.ts", + "workdir": str(WORKSPACE), + } + ), + }, + } + ) + lines.append( + { + "timestamp": iso(2_680), + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_cx2", + "output": ( + 'src/mcp/server.ts:39: version: "0.0.2",\n' + 'src/util/otel.ts:66: "service.version": "0.0.2",' + ), + }, + } + ) + lines.append( + { + "timestamp": iso(3_800), + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": ( + "Three version strings still say 0.0.2 " + "(package.json, mcp server, otel). Fix those " + "first. Recommendation: hold 30 min, then ship." + ), + } + ], + }, + } + ) + # token_count enriches the assistant message above + lines.append( + { + "timestamp": iso(3_900), + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "input_tokens": 4_220, + "cached_input_tokens": 1_100, + "output_tokens": 172, + "reasoning_output_tokens": 34, + "total_tokens": 4_426, + }, + "last_token_usage": { + "input_tokens": 4_220, + "cached_input_tokens": 1_100, + "output_tokens": 172, + "reasoning_output_tokens": 34, + "total_tokens": 4_426, + }, + "model_context_window": 258_400, + }, + }, + } + ) + write_jsonl(rollout_path, lines) + # Seed codex config so the permission view renders + (ROOT / ".codex").mkdir(exist_ok=True) + (ROOT / ".codex" / "config.toml").write_text( + '[projects."/tmp/agentwatch-demo/workspace"]\n' + 'trust_level = "trusted"\n' + ) + + +# ─── Gemini CLI ─────────────────────────────────────────────────────────── + + +def gemini_session() -> None: + session_id = "demo-gemini-0001" + project = "workspace" + path = ( + ROOT + / ".gemini" + / "tmp" + / project + / "chats" + / f"session-{time.strftime('%Y-%m-%dT%H-%M', time.gmtime(NOW_MS / 1000))}-{session_id[-8:]}.json" + ) + doc = { + "sessionId": session_id, + "projectHash": "demohash", + "startTime": iso(0), + "lastUpdated": iso(5_200), + "kind": "main", + "messages": [ + { + "id": "m1", + "timestamp": iso(300), + "type": "user", + "content": [{"text": "review ship 0.0.3 decision"}], + }, + { + "id": "m2", + "timestamp": iso(1_100), + "type": "gemini", + "content": "", + "thoughts": [], + "model": "gemini-2.5-pro", + "tokens": { + "input": 1_820, + "output": 18, + "cached": 0, + "thoughts": 64, + "tool": 0, + "total": 1_902, + }, + "toolCalls": [ + { + "id": "tc1", + "name": "read_file", + "args": {"file_path": "CHANGELOG.md"}, + "result": [ + { + "functionResponse": { + "id": "tc1", + "name": "read_file", + "response": { + "output": "# Changelog\n\n## [Unreleased]\n## [0.0.2] — …", + }, + } + } + ], + } + ], + }, + { + "id": "m3", + "timestamp": iso(2_400), + "type": "gemini", + "content": ( + "Ship 0.0.3 immediately. 0.0.2 is flagged buggy. " + "Every day delayed is a day users hit known issues." + ), + "thoughts": [], + "model": "gemini-2.5-pro", + "tokens": { + "input": 3_100, + "output": 142, + "cached": 1_820, + "thoughts": 220, + "tool": 0, + "total": 3_462, + }, + }, + ], + } + write_json(path, doc) + # settings.json so permission view renders + write_json( + ROOT / ".gemini" / "settings.json", + { + "security": {"auth": {"selectedType": "oauth"}}, + "selectedModel": "gemini-2.5-pro", + "tools": {"allow": ["read_file", "write_file"], "block": []}, + }, + ) + + +# ─── OpenClaw ───────────────────────────────────────────────────────────── + + +def openclaw_session() -> None: + session_id = "demo-openclaw-0001" + sub_agent = "research" + path = ( + ROOT / ".openclaw" / "agents" / sub_agent / "sessions" / f"{session_id}.jsonl" + ) + lines: list[dict] = [ + { + "type": "session", + "version": "1", + "id": session_id, + "timestamp": iso(0), + "cwd": str(WORKSPACE), + }, + { + "type": "model_change", + "id": "mc1", + "parentId": session_id, + "timestamp": iso(200), + "provider": "google", + "modelId": "gemini-2.5-pro", + }, + { + "type": "message", + "id": "om1", + "parentId": session_id, + "timestamp": iso(1_200), + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "[auraqu] research: map competitive positioning for agent observability tools", + } + ], + "timestamp": iso(1_200), + }, + }, + { + "type": "message", + "id": "om2", + "parentId": session_id, + "timestamp": iso(5_800), + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": ( + "[auraqu] Competitors: claude-devtools (Electron, " + "Claude-only), Claudex (web UI, Claude-only), " + "ccflare (proxy). agentwatch wins on multi-agent " + "+ OTel + MCP + anomaly." + ), + } + ], + "api": "google", + "provider": "google", + "model": "gemini-2.5-pro", + "usage": { + "input": 2_480, + "output": 312, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 2_792, + "cost": { + "input": 0.0031, + "output": 0.00312, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00622, + }, + }, + "stopReason": "end_turn", + "timestamp": iso(5_800), + "responseId": "rsp-1", + }, + }, + { + "type": "message", + "id": "om3", + "parentId": session_id, + "timestamp": iso(8_100), + "message": { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "ow1", + "name": "write", + "arguments": { + "file": "research/competitive-map.md", + "content": "# Competitive map\n\n...", + }, + "thoughtSignature": "demo", + } + ], + "api": "google", + "provider": "google", + "model": "gemini-2.5-pro", + "usage": { + "input": 1_120, + "output": 88, + "cacheRead": 2_480, + "cacheWrite": 0, + "totalTokens": 3_688, + "cost": { + "input": 0.0014, + "output": 0.00088, + "cacheRead": 0.00077, + "cacheWrite": 0, + "total": 0.00305, + }, + }, + "stopReason": "end_turn", + "timestamp": iso(8_100), + "responseId": "rsp-2", + }, + }, + ] + write_jsonl(path, lines) + # minimal roster file so permission view renders + write_json( + ROOT / ".openclaw" / "openclaw.json", + { + "defaultWorkspace": str(WORKSPACE), + "agents": { + "research": { + "name": "research", + "emoji": "🔬", + "model": "gemini-2.5-pro", + "workspace": str(WORKSPACE), + }, + "main": { + "name": "main", + "emoji": "🤖", + "model": "claude-opus-4-6", + "workspace": str(WORKSPACE), + }, + }, + }, + ) + + +# ─── Main ──────────────────────────────────────────────────────────────── + + +def main() -> None: + reset() + claude_session() + codex_session() + gemini_session() + openclaw_session() + print(f"✓ Mock HOME built at {ROOT}") + print() + print("Launch agentwatch against it:") + print(f" HOME={ROOT} WORKSPACE_ROOT={WORKSPACE} node bin/agentwatch.js") + print() + print("Asciinema session:") + print(f" asciinema rec docs/demo.cast --cols 110 --rows 34 --idle-time-limit 1.5") + print(f" # then inside: HOME={ROOT} WORKSPACE_ROOT={WORKSPACE} \\") + print(f" node bin/agentwatch.js") + + +if __name__ == "__main__": + main() diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..ebbef61 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/event-detail.png b/docs/event-detail.png new file mode 100644 index 0000000..32e6c76 Binary files /dev/null and b/docs/event-detail.png differ diff --git a/docs/features/agent-detection.md b/docs/features/agent-detection.md new file mode 100644 index 0000000..9b6d283 --- /dev/null +++ b/docs/features/agent-detection.md @@ -0,0 +1,73 @@ +# Agent detection + +## Contract + +**GOAL:** Surface which AI coding agents are installed and whether agentwatch instruments each one. +**USER_VALUE:** Answer "why am I not seeing events from ?" in one glance, without a support thread. +**COUNTERFACTUAL:** Silent miss-detection — a user thinks agentwatch covers their Codex runs while it isn't emitting. + +## What it does + +Surfaces which AI coding agents are installed on this machine, and +whether agentwatch is actually instrumenting each one (emits events) +or just recognizing it. + +## How to invoke + +- `agentwatch doctor` from the shell — prints the full table +- Inside the TUI: `a` toggles the right-hand agent panel + +## Inputs + +`detectAgents()` in `src/adapters/detect.ts` checks known paths: + +| Agent | Detection path | Instrumented | +|---|---|---| +| Claude Code | `~/.claude/projects/` | ✓ | +| OpenClaw | `~/.openclaw/` | ✓ | +| Cursor | `~/.cursor/` | ✓ (config-level) | +| Gemini CLI | `~/.gemini/` | ✓ | +| Codex | `~/.codex/` | not yet | +| Aider | `~/.aider.chat.history.md` or `~/.aider.input.history` | not yet | +| Cline (VS Code) | `Code/User/globalStorage/saoudrizwan.claude-dev/` | not yet | +| Continue.dev | `~/.continue/` | not yet | +| Windsurf | `~/.codeium/` | not yet | +| Goose (Block) | `~/.config/goose/` | not yet | + +## Outputs + +`DetectedAgent[]` with `{name, label, configPath?, present, instrumented}`. + +Doctor output: +``` +● Claude Code installed (events captured) +● Cursor installed (events captured) +○ Codex not detected +● Windsurf detected (events not yet captured — help us ship this) +``` + +When any detected-but-not-instrumented agent is present, doctor appends: +``` +Agents detected but not yet instrumented: + - Windsurf +If you want events captured for these, open an issue with a redacted +session file: https://github.com/mishanefedov/agentwatch/issues/new +``` + +Agent side panel inside the TUI applies the same `●` (green) / +`●` (yellow) / `○` (gray) color code. + +## Failure modes + +- **Cline macOS vs Linux path mismatch**: handled via `os.platform()` + branch. Windows is `os.platform() === 'win32'` which currently falls + through to the Linux path (incorrect for Windows, but Windows isn't + supported in v0). +- **Home dir not readable**: `existsSync` returns false safely. +- **Symlinked `~/.claude`**: `existsSync` follows symlinks. + +## Interactions + +- TUI agent panel updates live based on detection + event counts. +- When no instrumented agents are installed, the timeline shows + "waiting for activity…" — no events will ever arrive. diff --git a/docs/features/claude-hooks.md b/docs/features/claude-hooks.md new file mode 100644 index 0000000..a20ef28 --- /dev/null +++ b/docs/features/claude-hooks.md @@ -0,0 +1,101 @@ +# Claude Code native hooks (AUR-266) + +## Contract + +**GOAL:** Capture every Claude Code lifecycle event in real time via the official hooks API, alongside JSONL tailing. +**USER_VALUE:** Sub-1-second visibility into what Claude is doing — destructive `rm`, `.env` reads, prompt submits — instead of waiting on file-watcher debounces. Operators who run multiple Claude sessions can react before damage lands. +**COUNTERFACTUAL:** Without it, every Claude observation is delayed 1–2 seconds by JSONL polling and we miss sub-events that never reach the transcript. + +Claude Code ships a hooks API that runs a configured shell command on +every important event in the agent lifecycle (`SessionStart`, +`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`, +…). Hooks fire in real time — no JSONL parsing lag, no missed +sub-events, no waiting on file-watcher debounces. + +agentwatch can install itself as a hook and have Claude push every +event into our normal pipeline as it happens. + +## Install + +```sh +agentwatch hooks install +``` + +Writes hook stanzas into `~/.claude/settings.json` for every event +type we recognize. Each stanza is a one-line `curl` that POSTs the +hook payload (which Claude provides on stdin) to +`http://127.0.0.1:3456/api/hooks/`. The curl uses `-m 1` +(1-second timeout) and `exit 0` so a dead agentwatch never blocks +Claude. + +The stanzas are tagged `# [agentwatch-managed]` so we can find and +remove them later without disturbing user-defined hooks. + +```sh +agentwatch hooks status +``` + +Reports `installed`, `not-installed`, or `partial` (some events +managed, others not). `agentwatch doctor` includes the same line. + +```sh +agentwatch hooks uninstall +``` + +Removes only the agentwatch-tagged stanzas. Any other hooks the user +has configured stay in place. + +## How dedup works + +Hook events arrive about 1–2 seconds before the same event lands in +the JSONL transcript. To avoid double-counting, the JSONL adapter's +emit goes through `withClaudeHookDedup`, which: + +- For `claude-code` events with `details.source !== "hooks"` — looks + up `(sessionId, toolUseId)` in a 5-second registry. If marked, the + event is dropped. +- For `claude-code` events with `details.source === "hooks"` — bypass + the check (hook events never dedup against themselves) and continue + to the rest of the pipeline. + +The hook adapter calls `markHookSeen(sig)` *before* `sink.emit`, so by +the time the JSONL event arrives the registry has already been +updated. + +When `tool_use_id` is missing (hooks without an obvious correlation +key — `SessionStart`, `Notification`, etc.) we don't dedup; both +versions appear, but they represent fundamentally different shapes +(hooks have no token / cost data; JSONL has them) so the duplication +is informational rather than noisy. + +## Why this is in addition to the JSONL adapter, not a replacement + +Hooks deliver events *as they happen* — perfect for blocking-decision +work (the v0.3 control-plane bet) and for any operator who wants +sub-1s reaction time on `rm`, `.env` reads, etc. + +JSONL has the assistant's full response text, the tool result, the +extended-thinking block, token usage, and cost. Hooks don't. The two +sources are complementary; we keep both running and let dedup fall +out. + +## Configuration + +| Path | What | +|---|---| +| `~/.claude/settings.json` | Hook stanzas (managed by `hooks install`) | +| `~/.agentwatch/events.db` | Hook events land here just like everything else | + +There are no agentwatch-specific environment variables for hooks — +the install command resolves the port from `--port` / `AGENTWATCH_PORT` +and bakes it into the curl command at install time. + +## Out of scope (v0.1) + +- **Hook-based blocking** — Claude hooks support returning JSON with + `decision: "block"` to veto a tool call before it runs. That's the + control-plane bet on the v0.3 roadmap; the v0.1 adapter is + observe-only. +- **Hooks for agents other than Claude Code** — Cursor / Codex / + Gemini / OpenClaw don't ship a hooks API. JSONL or SQLite tailing + remains the path for them. diff --git a/docs/features/clipboard-yank.md b/docs/features/clipboard-yank.md new file mode 100644 index 0000000..672d09b --- /dev/null +++ b/docs/features/clipboard-yank.md @@ -0,0 +1,58 @@ +# Yank to clipboard + +## Contract + +**GOAL:** One-key copy of the most useful payload from the selected event or detail pane to the system clipboard. +**USER_VALUE:** Move an agent's prompt, tool call, or output into Slack or an editor in under a second — no mouse, no re-selection. +**COUNTERFACTUAL:** Users screenshot the TUI or retype tool commands, losing fidelity and time. + +## What it does + +Press `y` on any selected timeline row or inside the detail pane. +Copies the most useful payload to the system clipboard. Flash message +at the footer confirms success or surfaces a reason on failure. + +## How to invoke + +- `y` with a row selected → copies the selected event's payload +- Confirmation: `✓ copied N chars to clipboard` (green) for 2 seconds +- Failure: `✗ ` (red) for 2 seconds + +## Inputs + +`eventToYankText(summary, path, cmd, toolResult, fullText)` in +`src/util/clipboard.ts` picks the most useful text, in order: +1. `toolResult` — full tool output when available +2. `fullText` — prompt / response text +3. `cmd` — shell command +4. `path` — file path +5. `summary` — last-resort fallback + +`copyToClipboard(text)` dispatches per platform: +- macOS: `pbcopy` +- Linux: `wl-copy` → `xclip -selection clipboard` → `xsel` + (first-available wins) +- Windows: `clip` + +Explicit `stdio: ['pipe', 'ignore', 'ignore']` on every spawnSync so +Ink's raw-mode TTY doesn't produce `EBADF` on child processes. + +## Outputs + +Text on the system clipboard. No disk artifacts. + +## Failure modes + +- **No clipboard tool available** (headless Linux without xclip / xsel / + wl-copy). Result: `{ok: false, reason: "install wl-copy / xclip / xsel + for clipboard support"}`. Flash shows reason. +- **Tool exits non-zero.** `{ok: false, reason: "xclip exited N"}`. +- **Nothing to yank** (empty strings everywhere). No-op, no flash. + +## Interactions + +- Selection state (`selectedIdx`) is required. Pressing `y` without a + selection is a silent no-op. +- Doesn't change timeline state — pure side effect. +- Flash uses the `flash` / `flash-clear` reducer actions; a 2-second + `setTimeout` clears. diff --git a/docs/features/compaction-visualizer.md b/docs/features/compaction-visualizer.md new file mode 100644 index 0000000..86fdf26 --- /dev/null +++ b/docs/features/compaction-visualizer.md @@ -0,0 +1,73 @@ +# Compaction visualizer + +## Contract + +**GOAL:** Show, per session, where the agent's context window filled up and where it was reset, so operators can attribute behavior changes to a specific compaction event. +**USER_VALUE:** Spot the moment a session "forgot" earlier context — the cause of "why did it suddenly redo work?" — without re-reading the full timeline. +**COUNTERFACTUAL:** Without compaction markers, the timeline implies an infinitely growing context. Operators chase phantom regressions caused by silent resets. + +## What it does + +Per session, walks the assistant turns in order and produces one +`CompactionPoint` per turn (token fill %) or per compaction marker +(fill-before + fill-after of the reset). Renders as a horizontal bar +chart inside the TUI (`C` key from a scoped session). + +Built from `src/util/compaction.ts` → `buildCompactionSeries(events, sessionId, window)`. + +## Per-agent support matrix (AUR-214) + +The visualizer is only as good as the underlying compaction signal each +adapter can extract. State as of this commit: + +| Agent | Compaction marker emitted | Source | +|---|---|---| +| Claude Code | ✓ | `isCompactSummary: true` on user turns in `~/.claude/projects//.jsonl` | +| Codex | ✓ | `event_msg` payload of type `turn_truncated` (or future `compaction`) in `~/.codex/sessions/rollout-*.jsonl` | +| Gemini CLI | ✗ | **Structural limitation** — Gemini chat JSON (`~/.gemini/tmp//chats/session-*.json`) carries only `user`/`gemini`/`error`/`info` message types. The CLI's `/compress` command rewrites context in-place but does not persist a marker. We surveyed every session under `~/.gemini/tmp/*/chats/` and found no compaction-shaped record. The visualizer therefore has no compaction events to plot for Gemini sessions; the fill curve will show monotonically growing input until the chat ends. | +| OpenClaw | ✗ | **Structural limitation** — `~/.openclaw/agents/*/sessions/*.jsonl` records `session`, `message`, `model_change`, `thinking_level_change`, `custom`, `custom_message`. The `custom` event subtypes seen in the wild are `model-snapshot`, `openclaw:bootstrap-context:full`, `openclaw:prompt-error`. The `custom_message` subtype `openclaw.sessions_yield` is a parent → child handoff signal, not a context reset. We checked every active OpenClaw session and found nothing equivalent to `isCompactSummary`. | +| Cursor | n/a | Cursor's session storage is config-only in our adapter (no full activity stream). | +| Hermes | n/a | Hermes uses a SQLite store with prompt/response messages; no compaction protocol surfaced. | + +### What changes if Gemini or OpenClaw start persisting a marker + +If a future Gemini CLI release adds a chat-compress marker to the JSON +(e.g. an `info` message with content matching `^Context compressed:`, +or a new `type: "compaction"` block), wire it through +`src/adapters/gemini.ts` to emit `EventType: "compaction"` with an +appropriate `summary`. + +For OpenClaw, the most plausible signal is a future `custom`-event +`customType: "openclaw:context:compact"` (or similar). Add a branch in +`src/adapters/openclaw.ts → translateSession` that emits +`EventType: "compaction"` for that type. + +Until then: don't synthesize compaction events from indirect signals +(big drop in `cacheRead`, model swap, etc.) — false positives in this +view are worse than the current honest blank. + +## How to invoke + +- Inside the TUI: drill into a session, then press `C`. +- Per-session API: `GET /api/sessions/:id/compaction` (renders the same + series as JSON for the web UI). + +## Failure modes + +- **Unknown context window for the model.** Falls back to 200k. The + fill axis is then proportional but not absolute. Operators can + override via `AGENTWATCH_CONTEXT_WINDOW=`. +- **Adapter never emits `EventType: "compaction"`.** Visualizer plots + fill but no reset markers — see matrix above. +- **Session events arrive out of order.** `buildCompactionSeries` + walks events in `ts` order; clock skew is clamped at the schema layer + (see `clampTs`). + +## Interactions + +- The fill axis uses `details.usage.{input, cacheRead}` from the + per-turn enrichment (cost adapter). Sessions without usage data + produce a flat zero curve. +- Anomaly detection (AUR-180) does not yet consume compaction series + but the n-gram detector intentionally treats post-compaction turns as + fresh windows. diff --git a/docs/features/cost-accounting.md b/docs/features/cost-accounting.md new file mode 100644 index 0000000..669eaa4 --- /dev/null +++ b/docs/features/cost-accounting.md @@ -0,0 +1,110 @@ +# Cost with cache-hit accounting + +## Contract + +**GOAL:** Per-turn USD cost from real usage data, aggregated per agent, session, and project. +**USER_VALUE:** Spot a runaway agent or spend spike before the monthly invoice shows up. +**COUNTERFACTUAL:** Cost stays opaque until the provider invoice; users can't tie spend to a specific session or task. + +## What it does + +Per-assistant-turn USD cost, computed from the Claude `message.usage` +object. Aggregated per agent, per session, per project. + +## How to invoke + +Automatic. Visible in three places: +- Agent side panel: per-agent total (yellow) +- Sessions list: per-session cost +- Event detail pane: per-turn breakdown + +## Inputs + +`parseUsage()` in `src/util/cost.ts` extracts four fields from the +message's `usage` object: +- `input_tokens` +- `cache_creation_input_tokens` (billed at ~125% of input) +- `cache_read_input_tokens` (billed at ~10% of input) +- `output_tokens` + +Rate table per model lives in `src/util/cost.ts` (DEFAULT_RATES): +- `claude-opus-4-6` +- `claude-sonnet-4-6` +- `claude-haiku-4-5` +- `gemini-2.5-pro`, `gemini-2.5-flash` +- `gpt-5`, `gpt-5-mini` +- `default` fallback (uses sonnet rates) + +### Overriding pricing locally (AUR-216) + +Provider rates change between releases. Operators can override the +shipped defaults without rebuilding the CLI by writing a JSON file at +`~/.agentwatch/pricing.json` (or wherever `AGENTWATCH_PRICING_PATH` +points): + +```json +{ + "claude-opus-4-6": { + "input": 15.0, + "cacheCreate": 18.75, + "cacheRead": 1.5, + "output": 75.0 + }, + "my-local-model": { + "input": 0, + "cacheCreate": 0, + "cacheRead": 0, + "output": 0 + } +} +``` + +Rules: +- Keys are the **normalized** model name (e.g. `gpt-5` not `gpt-5.4-preview`; + see `normalizeModel` in `cost.ts`). +- Values are USD per **million** tokens. +- All four fields (`input`, `cacheCreate`, `cacheRead`, `output`) must be + non-negative numbers — partial entries are dropped (we never silently + use a stale field). +- Unknown / missing models fall back to `default` from `DEFAULT_RATES`. +- The file is read once at adapter startup. Restart agentwatch to pick + up edits. +- Set `AGENTWATCH_PRICING_DEBUG=1` to log validation rejections. + +`costOf(model, usage)` returns USD as a float. `formatUSD(n)` formats with +adaptive precision: +- n < 0.01 → `$0.0042` (4 decimals) +- n < 1 → `$0.840` (3 decimals) +- n ≥ 1 → `$12.40` (2 decimals) + +## Outputs + +Stashed on `event.details`: +- `usage` — the raw four-number object +- `cost` — computed float +- `model` — normalized model string + +## Failure modes + +- **Unknown model.** Falls back to sonnet rates. Displayed cost may be + inaccurate for that turn but non-zero. +- **Missing `usage` object.** `parseUsage` returns null; no cost stashed. +- **Rate table stale.** Hardcoded quarterly — update in + `src/util/cost.ts`. Mismatch shows as under/over-estimate. + +## Why cache accounting matters + +Naive summers that treat `cache_read_input_tokens` at full input rate +are **3–10× wrong** on Claude. A turn that reads 42,335 cached tokens + +439 new cache tokens + 6 pure input tokens + 249 output tokens: +- Naive: `42,335 × $15 / 1M + 249 × $75 / 1M = $0.65` +- Correct: `42,335 × $1.50 / 1M + 439 × $18.75 / 1M + 6 × $15 / 1M + 249 × $75 / 1M = $0.089` + +Orders of magnitude matter when budgeting. + +## Interactions + +- Detail pane breakdown reveals the token split — useful for verifying. +- Budget alarms (v0.5, AUR-109) will use the same totals. +- OTel exporter (v0.5, AUR-110) will include `cost.usd` as a semantic + attribute. diff --git a/docs/features/event-detail.md b/docs/features/event-detail.md new file mode 100644 index 0000000..a07ca9f --- /dev/null +++ b/docs/features/event-detail.md @@ -0,0 +1,57 @@ +# Event detail pane + +## Contract + +**GOAL:** Full-screen overlay exposing every field of one selected event — prompt, tool input, tool result, thinking, tokens, cost. +**USER_VALUE:** Debug what an agent actually sent and received without tailing JSONL files by hand. +**COUNTERFACTUAL:** Users drop to a terminal, grep raw logs, and lose the context of the surrounding timeline. + +## What it does + +Full-screen overlay showing every field of the selected event: token +usage, cost, duration, full prompt/response text, tool input JSON, tool +result (stdout / file body / search matches), extended thinking. + +## How to invoke + +- Select a row with `↑↓` / `j k` +- Press `Enter` +- Inside the pane: `↑↓` / `j k` scrolls; `esc` closes + +## Inputs + +Reads the selected `AgentEvent.details`: +- `fullText` — prompt or response text +- `thinking` — extended-thinking blocks +- `toolInput` — tool_use arguments (JSON) +- `toolResult` — paired tool_result content +- `durationMs` — tool_use → tool_result delta +- `usage`, `cost`, `model` — per-turn token cost +- `toolError` — `is_error: true` flag + +## Outputs + +Rows grouped by section: +- Metadata (time, agent, type, tool, path, cmd) +- "tokens / cost / duration" block (only for assistant turns) +- "tool result" or "tool result (error)" — full output, red-colored on + error, capped at 256 KB via `capBytes()` +- "text" — wrapped to terminal width +- "extended thinking" — dimmed +- "tool input" — JSON-pretty +- Pagination footer `1–20 of 47 ↑↓ scroll [esc] close` + +## Failure modes + +- **Event has no details at all.** Shows "(no additional content captured + for this event)". +- **`toolResult` exceeds 256 KB.** Truncated with `[N bytes truncated]` + suffix (the cap is applied by the claude adapter at ingestion time). +- **Terminal resized while open.** Scroll offset clamps to the new max. + +## Interactions + +- Works with every event type. Some events are sparse (e.g. `session_start` + just has metadata); that's intentional, not a bug. +- Does not change the underlying timeline filter/scope — closing returns + to the exact same view. diff --git a/docs/features/fs-watcher.md b/docs/features/fs-watcher.md new file mode 100644 index 0000000..d883d77 --- /dev/null +++ b/docs/features/fs-watcher.md @@ -0,0 +1,58 @@ +# Filesystem watcher + +## Contract + +**GOAL:** Emit timeline events for workspace file changes that no instrumented adapter attributed. +**USER_VALUE:** Catch writes from manual edits or non-instrumented agents (Aider, Cline, Windsurf) so the timeline isn't blind. +**COUNTERFACTUAL:** Multi-agent users see a partial picture — only the instrumented agents — while unrelated writes silently change their tree. + +## What it does + +A catch-all for file changes in your workspace that didn't come from +an instrumented agent. Fires for manual edits, non-instrumented agents +(Aider, Codex, Cline, Windsurf when they land), and any other source. + +Deduped against agent-attributed writes so Claude/OpenClaw writes don't +appear twice. + +## How it starts + +Automatic at App mount. Watches `$WORKSPACE_ROOT` (default +`~/IdeaProjects`, fallback chain: `~/src` → `~/code` → `~/Projects` → +`~/dev` → `$HOME`). + +## Inputs + +`src/adapters/fs-watcher.ts`: +- chokidar `depth: 3` +- Ignore list: `node_modules`, `.git`, `dist`, `build`, `.next`, + `.cache`, `.turbo`, `target`, `coverage`, `.venv`, `venv`, + `__pycache__`, `.pytest_cache`, `.idea`, `.vscode`, `*.log`, `*.lock`, + `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, + `.DS_Store` +- Checks `wasRecentlyWrittenByAgent(path)` from + `src/util/recent-writes.ts` before emitting — skips dedupe window + (5s TTL, 30s purge) + +## Outputs + +Emits `type: "file_change"` events with `agent: "unknown"`. Summary = +the path. Risk computed per path pattern (reads of `.env` flagged). + +## Failure modes + +- **EMFILE / ENOSPC / EACCES**: swallowed silently. The watcher's error + handler recognizes these and does not crash the process. +- **`$WORKSPACE_ROOT` doesn't exist**: falls through the default chain + until one exists. Ultimately defaults to `$HOME`. +- **Huge workspace (>40k files)**: chokidar will struggle even with + `depth: 3`. Not fully solved; documented as a known limitation. v0.4 + could add an auto-disable on EMFILE. + +## Interactions + +- Deduped against Claude + OpenClaw + Cursor adapter writes via + `recent-writes.ts`. An adapter that writes a file marks the path; fs + watcher skips that path for the next 5 seconds. +- `fs-watcher` is the fallback observability channel for + detected-but-not-instrumented agents (Aider, Codex, Cline, Windsurf). diff --git a/docs/features/mcp-server.md b/docs/features/mcp-server.md new file mode 100644 index 0000000..31e87c7 --- /dev/null +++ b/docs/features/mcp-server.md @@ -0,0 +1,99 @@ +# agentwatch MCP server + +Expose your local agent history to any MCP-compatible client (Claude +Code, Cursor, custom LangChain/CrewAI agents, …) so running agents can +inspect what they — or other agents — did before. All local. No +network. No telemetry. + +## Contract + +**GOAL:** Serve this machine's agent history via MCP so any client can query it without cloud or custom wiring. +**USER_VALUE:** A running agent can ask "what did I do last session?" and hand context to the next agent without manual copy-paste. +**COUNTERFACTUAL:** Agents repeatedly re-discover what a prior run already learned; no cross-agent handoff across the multi-agent stack. + +## Quick start + +```bash +# Option A — if agentwatch is on your PATH (npm i -g) +claude mcp add agentwatch -- agentwatch mcp + +# Option B — npx on demand +claude mcp add agentwatch -- npx -y @misha_misha/agentwatch mcp +``` + +Restart Claude Code, then run `/mcp` inside the TUI — `agentwatch` +should appear with 5 tools listed. + +## Manual install (edit config by hand) + +### Claude Code + +Edit `~/.claude.json` (or `~/.claude/config.json` depending on your +version): + +```json +{ + "mcpServers": { + "agentwatch": { + "command": "npx", + "args": ["-y", "@misha_misha/agentwatch", "mcp"] + } + } +} +``` + +### Cursor + +Edit `~/.cursor/mcp.json` (or use Cursor's "Add MCP Server" UI): + +```json +{ + "mcpServers": { + "agentwatch": { + "command": "npx", + "args": ["-y", "@misha_misha/agentwatch", "mcp"] + } + } +} +``` + +### Generic MCP client (stdio) + +Any client that speaks MCP over stdio. Example: + +```json +{ + "command": "npx", + "args": ["-y", "@misha_misha/agentwatch", "mcp"] +} +``` + +## Tools exposed + +| Tool | Args | Returns | +|---|---|---| +| `list_recent_sessions` | `limit?: 1-100` | `[{agent, sessionId, project, lastActivity, sizeBytes}]` newest first | +| `get_session_events` | `sessionId: string`, `maxBytes?: 1024-10_000_000` | Raw JSONL lines for that session (tail-capped) | +| `search_sessions` | `query: string`, `limit?: 1-50` | `[{session, agent, line}]` substring hits | +| `get_tool_usage_stats` | `sessionId?: string`, `limit?: 1-500` | `{scannedSessions, turns, tools: [{tool, count, totalDurationMs, errorCount}]}` | +| `get_session_cost` | `sessionId: string` | `{totalCostUsd, turns, tokens:{input,cacheRead,cacheCreate,output}, byModel}` | + +## Example agent prompts + +After wiring it up, try asking your agent: + +- *"Use agentwatch to list my five most recent sessions and summarize what I was working on."* +- *"Use agentwatch get_tool_usage_stats to tell me which tools Claude has been failing on most often this week."* +- *"How much have I spent on session \ today? Use agentwatch."* + +## Notes + +- **All local.** The server reads `~/.claude/projects/**/*.jsonl` and + `~/.codex/sessions/**/*.jsonl` directly; no data leaves your machine. +- **Fresh every request.** Unlike the TUI, the MCP server does not + maintain in-memory state — each call re-reads from disk. That means + edits to sessions are reflected immediately, but heavy bulk queries + (`get_tool_usage_stats` without `sessionId`) scan up to `limit` + session files. +- **No write tools.** The server is read-only by design — an agent can + look but can't modify session history. diff --git a/docs/features/notifications.md b/docs/features/notifications.md new file mode 100644 index 0000000..fcf03f4 --- /dev/null +++ b/docs/features/notifications.md @@ -0,0 +1,115 @@ +# Desktop notifications + +## Contract + +**GOAL:** OS-native desktop notifications for high-risk events, rate-limited and silent during backfill. +**USER_VALUE:** Know the moment an agent runs something destructive, even when the TUI isn't focused. +**COUNTERFACTUAL:** Users must babysit the TUI to catch dangerous events in time; a shell_exec rm lands unnoticed. + +## What it does + +Fires OS-native notifications on sensitive events. Rate-limited so a +looping agent doesn't spam alerts. Silent during backfill so launching +agentwatch doesn't dump historical alerts. + +## How to invoke + +Automatic. Dispatched from `src/util/notifier.ts`. Built-in rules are +hardcoded; on top of those the user can add their own triggers in +`~/.agentwatch/triggers.json` (see **Custom triggers** below). + +## Inputs + +Every new event passes through `shouldNotify(event)`: + +- **`.env` access** — `file_read` or `file_write` on a path matching + `(^|/)\.env($|\.)`. Keyed by path. +- **Credential paths** — any path matching + `(^|/)(\.ssh|\.aws|\.gnupg)($|/)`. Keyed by path. +- **Destructive shell** — `shell_exec` with `\brm\s+-rf\b`, `\bsudo\b`, + or `curl[^|]*\|\s*(sh|bash)`. Keyed by command prefix. +- **Tool errors** — `details.toolError === true`. Keyed by tool + session. +- Events whose `ts` is before `launchedAt` are silently skipped + (backfill). + +## Outputs + +`notify(title, body)` in `notifier.ts` dispatches: +- macOS: `osascript -e 'display notification …'` +- Linux: `notify-send <body>` +- Windows: PowerShell `MessageBox` fallback + +Rate limiter (`gate(key)`): one alert per rule-key per 60 seconds. + +`stdio: ['ignore', 'ignore', 'ignore']` on every spawnSync so a missing +`notify-send` or a failed `osascript` never clobbers the Ink TUI. + +If any platform call throws, the whole notifier self-disables for the +session (avoids a broken install spamming stderr). + +## Failure modes + +- **macOS notification daemon disabled.** `osascript` returns + non-zero; we swallow it. +- **Linux without notify-send / no DBus session.** Exit code non-zero; + swallowed. +- **Windows without PowerShell.** Extremely rare; swallowed. +- **Running inside SSH with no local desktop.** Notifications fire on + the local machine of whichever `agentwatch` process handles them. + SSH remote notifications are future work. + +## Custom triggers + +Create `~/.agentwatch/triggers.json` with an array of rule objects. +**Edits are picked up live** — no restart required. + +```json +[ + { + "match": "curl .* \\| (bash|sh)", + "title": "pipe-to-shell", + "body": "{{agent}}: {{cmd}}" + }, + { + "type": "file_write", + "pathMatch": "^/etc/", + "title": "/etc write", + "body": "{{agent}} wrote {{path}}" + }, + { + "thresholdUsd": 0.5, + "title": "expensive turn", + "body": "turn cost {{cost}}" + } +] +``` + +### Rule fields + +| Field | Type | Purpose | +|---|---|---| +| `match` | string (regex) | Tested against `summary\ncmd\npath` | +| `pathMatch` | string (regex) | Tested against `path` only — narrower | +| `type` | string | Restrict rule to a specific event type | +| `thresholdUsd` | number | Minimum per-turn cost before firing | +| `title` | string (required) | Notification title | +| `body` | string (required) | Notification body | + +### Placeholders + +`{{agent}}`, `{{type}}`, `{{cmd}}`, `{{path}}`, `{{tool}}`, +`{{summary}}`, `{{cost}}`. Unknown tokens expand to the empty string. + +### Failure modes (triggers) + +- Invalid regex → rule skipped silently. +- Rule missing `title` or `body` → dropped. +- Unreadable file → no user rules; built-ins still fire. +- Per-title rate limit (60s) still applies. + +## Interactions + +- Doesn't emit events or mutate state — side effect only. +- Rate-limit state is process-local (not persisted). Restarting + agentwatch resets the cache. +- Triggers file is watched via chokidar; edits take effect on next event. diff --git a/docs/features/permissions.md b/docs/features/permissions.md new file mode 100644 index 0000000..8867309 --- /dev/null +++ b/docs/features/permissions.md @@ -0,0 +1,63 @@ +# Permissions view + +## Contract + +**GOAL:** Side-by-side view of every agent's permission and config surface, flagging dangerous patterns. +**USER_VALUE:** Audit "what is each agent allowed to do" in one screen instead of reading five config files. +**COUNTERFACTUAL:** Permission drift goes unnoticed; a dangerous allowlist entry survives until it fires. + +## What it does + +Press `p` to see a scrollable view of every agent's permission / +configuration surface side-by-side. Flags dangerous patterns. + +## How to invoke + +- `p` opens the view +- `↑↓` / `j k` scrolls +- `p` or `esc` closes + +## Inputs + +Read at mount time, refreshed on every render (files are cheap to +re-read): +- **Claude**: `~/.claude/settings.json` — `permissions.allow` / + `permissions.deny` / `defaultMode` / `additionalDirectories`. Project + `.claude/settings.json` + `.claude/settings.local.json` also read if + present. +- **Cursor**: `CursorStatus` object collected at startup in the cursor + adapter — MCP servers, approval mode, sandbox, allow/deny counts, + `.cursorrules` paths. +- **OpenClaw**: `~/.openclaw/openclaw.json` — default workspace, + per-agent list with name/model/workspace/identity. + +Gemini CLI exposes no permission model beyond auth; documented + +omitted. + +## Outputs + +Section-per-agent with colored titles (cyan / magenta / yellow) and: +- Metadata rows (source, defaultMode, approval mode, sandbox) +- `CAN (N)` block with green ✓ per allow rule +- `CANNOT (N)` block with red ✗ per deny rule +- Flagged risks — yellow warnings or red errors: + - `Bash(*)` allow → arbitrary shell + - Write/Edit allowed with no `~/.ssh`/`.aws`/`.gnupg` deny + - Empty deny list + - `defaultMode=auto` or `bypassPermissions` + +Footer pagination `N–M of total ↑↓ scroll [p] close [q] quit`. + +## Failure modes + +- **settings.json missing.** Shows "No settings.json found." +- **settings.json malformed.** Try/catch around JSON.parse; section + shows placeholder. +- **OpenClaw not installed.** Section shows "not detected". +- **Cursor not installed.** Same. + +## Interactions + +- Independent of timeline — opening permissions doesn't change filters. +- Flags produce a visual cue only; no events are emitted. (Live flag + detection → notifications is AUR-108 regex triggers.) diff --git a/docs/features/projects-nav.md b/docs/features/projects-nav.md new file mode 100644 index 0000000..e6214cb --- /dev/null +++ b/docs/features/projects-nav.md @@ -0,0 +1,54 @@ +# Projects navigation + +## Contract + +**GOAL:** Grid of every workspace path seen by any agent; Enter scopes to that workspace's sessions. +**USER_VALUE:** Jump between projects on a multi-repo machine without typing paths or guessing session ids. +**COUNTERFACTUAL:** Users scroll a mixed timeline that mashes every project together — slow to orient. + +## What it does + +Press `P` to open a projects grid aggregating every workspace path across +every installed agent. Pick one → drill into its sessions list (see +[sessions-nav](./sessions-nav.md)). + +## How to invoke + +- `P` (uppercase) opens the grid +- `↑↓` / `j k` to move selection +- `Enter` opens the sessions list for the selected project +- `esc` closes + +## Inputs + +Derived from the event buffer via `buildProjectIndex()` in +`src/util/project-index.ts`: +- **Project name** = first-bracketed tag of each event's summary + (`[auraqu]`, `[_content_agent_]`). Claude derives from session-file + path; OpenClaw from `cwd` captured at `session_start`; Cursor from + a path heuristic; Gemini from `~/.gemini/tmp/<dir>/chats/`. +- **Per-agent counts** from `byAgent` map +- **Cost** summed across `event.details.cost` +- **Last activity** = max `ts` of any event in the project +- **Session count** = size of the `sessions` set (unique sessionIds) + +## Outputs + +Rows sorted descending by last activity. Each row shows: +- Name (26 chars) +- Time-ago (5m ago / 2h ago / 3d ago) +- Cost (yellow) +- Sub-line: event count · session count · per-agent breakdown + +## Failure modes + +- **Event summary lacks a bracket tag.** Event is excluded from the index + (fs-watcher events for un-attributed file changes fall in this bucket). +- **Rapid project switching** (>100 projects). Rendering stays cheap; the + index is O(n) and builds on every render via a plain for-loop. + +## Interactions + +- Enter → sessions list scoped to that project. +- After returning to the timeline, the project scope persists as a + yellow breadcrumb. `Z` clears it; `0` resets everything. diff --git a/docs/features/search.md b/docs/features/search.md new file mode 100644 index 0000000..9c8cc85 --- /dev/null +++ b/docs/features/search.md @@ -0,0 +1,51 @@ +# Search + +## Contract + +**GOAL:** In-buffer full-text filter over the live timeline, with sticky query and breadcrumb. +**USER_VALUE:** Find the one event you need in a 500-row buffer in under a second — no scrolling, no missed matches when the stream moves. +**COUNTERFACTUAL:** Users must scroll and eyeball-filter; matches escape when rows scroll off under load. + +## What it does + +In-buffer full-text filter. Press `/`, type a query, the timeline narrows +to matches. + +## How to invoke + +- `/` opens the input line +- Type — timeline updates live +- `Backspace` edits +- `Enter` exits input mode but **keeps** the query as a sticky filter +- `esc` clears the query and exits +- While the query is active, a breadcrumb shows `search "<query>"` + +## Inputs + +`matchesQuery(event, query)` in `src/ui/state.ts`: +- Case-insensitive substring search +- Checks: summary, path, cmd, tool, agent, `details.fullText`, + `details.thinking` + +## Outputs + +- Filtered timeline +- Match count shown below the timeline (`matches: 45`) +- Yellow blinking cursor `▌` while in input mode +- Breadcrumb integrates with other active scopes + +## Failure modes + +- **Empty query.** No filter applied. +- **Regex-special characters in query.** Treated as literals — we do + substring, not regex. Regex/glob support is tracked for v0.5 + (AUR-108 custom triggers). +- **Huge result set.** The timeline window caps at 40 rendered rows + regardless. + +## Interactions + +- Stacks with agent filter + project filter + session filter + subagent + scope. All filters are AND'd. +- Cross-session disk search (ripgrep over all jsonl files) is a separate + v0.5 feature (AUR-111). diff --git a/docs/features/sessions-nav.md b/docs/features/sessions-nav.md new file mode 100644 index 0000000..ddddaf2 --- /dev/null +++ b/docs/features/sessions-nav.md @@ -0,0 +1,58 @@ +# Sessions navigation + +## Contract + +**GOAL:** Date-bucketed list of every session for one project, across every agent; Enter scopes the timeline. +**USER_VALUE:** Review yesterday's Claude session and today's Codex session for the same repo in seconds. +**COUNTERFACTUAL:** Users manually match timestamps across five JSONL dirs to reconstruct a session. + +## What it does + +After picking a project, shows every session for that project across +every agent, bucketed by date (Today / Yesterday / Last 7 days / Older). +Pick a session → scope the timeline to only its events. + +## How to invoke + +- From the projects grid, `Enter` on a project +- `↑↓` / `j k` to move through session rows (buckets are skipped) +- `Enter` scopes the main timeline to that session +- `esc` returns to projects + +## Inputs + +`buildSessionRows(events, project)` in `src/util/project-index.ts`: +- Groups `events` by `sessionId` where the event's `project` equals the + selected project +- For each session: records the first user prompt text, event count, + first/last timestamp, total cost, and whether any event had + `toolError: true` +- Rows sorted descending by `lastTs` +- Date bucket computed by `dateBucket(lastTs)`: + `today` | `yesterday` | `7d` | `older` + +## Outputs + +Each row: +- Yellow selection marker +- Colored agent tag (cyan=claude-code, yellow=openclaw, magenta=cursor, + blue=gemini, green=codex) +- First user prompt truncated to 56 chars (falls back to "(no user + prompt yet)") +- Event count + time-ago + cost (yellow) +- Red `· ERR` suffix if any event errored + +## Failure modes + +- **Session with no user prompt.** Shows placeholder string. +- **Session with only a session_start + no messages.** Rendered with + event count 1 and placeholder prompt. +- **Large project (>500 sessions).** All render; scroll works fine. + +## Interactions + +- Enter on a session → sessionFilter applied to timeline (scoped view). + Breadcrumb shows `session <id8>`. +- `Z` clears session filter. `0` clears everything. +- Detail pane (`Enter`), search (`/`), yank (`y`) all work inside a + scoped session. diff --git a/docs/features/subagent-drilldown.md b/docs/features/subagent-drilldown.md new file mode 100644 index 0000000..2b2e1a1 --- /dev/null +++ b/docs/features/subagent-drilldown.md @@ -0,0 +1,64 @@ +# Subagent drilldown + +## Contract + +**GOAL:** Scope the timeline to a single Claude-Code subagent's inner tool calls. +**USER_VALUE:** Post-mortem "what did this delegated task actually do" without parsing the parent session log. +**COUNTERFACTUAL:** Subagent activity drowns in the parent session; failed delegations go unnoticed. + +## What it does + +Claude Code's `Task` (Agent) tool spawns a subagent with its own inner +tool calls. Claude writes those into +`~/.claude/projects/<proj>/<session>/subagents/agent-<id>.jsonl`. + +agentwatch ingests both the parent session and every subagent file, and +lets you scope the timeline to one subagent's inner calls on demand. + +## How to invoke + +- Find a parent `Agent` tool_use event. They show `▸ N child events` in + the row suffix. +- Press `x` with that row selected. +- Timeline now shows only events from that subagent's run. +- Press `X` (shift-x) to unscope. + +## Inputs + +- Parent Claude Code assistant message with `tool_use.name === "Agent"`. + Its tool_use_id is recorded. +- Parent's matching tool_result. The result's flattened content contains + `"agentId":"<hex>"` or `agentId: <hex>` (extracted by regex in the + claude adapter). +- Subagent JSONL file at + `…/<mainSessionId>/subagents/agent-<agentId>.jsonl` (watched via the + same adapter, tagged `sessionId: "agent-<agentId>"`). + +## Outputs + +- Parent Agent event row gets `▸ N child events` appended, where N is the + count of events whose `sessionId === "agent-<subAgentId>"`. +- When scoped (`x`), a yellow breadcrumb segment `sub <agentId8>` shows + in the header, and the timeline filters to matching events. +- `X` removes the scope and restores whatever filters were active. + +## Failure modes + +- **Parent tool_result never arrives.** The subagent events still appear + in the timeline (they were ingested from their own file), but no + `subAgentId` is attached to the parent, so `x` does nothing on that + row. +- **agentId regex fails to match** (format changed upstream). Same + degradation — events visible, drilldown link missing. Safe failure. +- **Subagent file is created before parent tool_result** (rare). Events + surface; drilldown activates when the result is paired. + +## Interactions + +- Combines with agent filter + project filter + search: scoping by + subagent further narrows the already-filtered view. +- Inside a scoped view, the detail pane, search, and yank all work as + normal. +- OpenClaw sub-agents are a different concept: each OpenClaw sub-agent + (content, research, docs, main) is its own parallel session — see + [sessions-nav](./sessions-nav.md). diff --git a/docs/features/timeline.md b/docs/features/timeline.md new file mode 100644 index 0000000..cc4fe6f --- /dev/null +++ b/docs/features/timeline.md @@ -0,0 +1,59 @@ +# Timeline + +## Contract + +**GOAL:** Unified reverse-chronological view of every event from every instrumented agent on this machine. +**USER_VALUE:** One pane to triage multi-agent activity — Claude, Codex, OpenClaw, Gemini side by side, nothing to correlate manually. +**COUNTERFACTUAL:** Users tail five JSONLs in five terminals and correlate timestamps by eye. + +## What it does + +The main view. Every event emitted by any installed agent streams into a +reverse-chronological list. Each row: timestamp · agent · event type · +`[project / sub-agent]` summary · duration · error flag. + +## When it fires / how to invoke + +Opened by default when you run `agentwatch`. Filters stack: agent filter +(`f`), project filter (selected via `P`), session scope (via sessions +list), subagent scope (`x` on an Agent tool_use event), search (`/`). + +## Inputs + +- Live reads from adapters: Claude Code, OpenClaw, Cursor (config), + Gemini CLI. +- Event buffer capped at `MAX_EVENTS = 500`. Older events fall off the + tail. +- Each event carries its canonical `ts` (ISO) — sort order is strictly + reverse-chronological by `ts`, not by arrival order. + +## Outputs + +- Ink-rendered TUI rows, one per event +- Columns sized to terminal width; content truncated with ellipsis rather + than wrapping (so every event is exactly one line) +- Risk-based coloring: green (file_read) / white (tool_call) / orange + (file_write) / red (shell_exec with destructive pattern) +- When selection is active (`↑↓`), selected row is inverse-highlighted + +## Failure modes + +- **Backfill arrives out of order.** Resolved via binary-insert by `ts` on + every incoming event. +- **Incoming event has `ts` in the future.** Clamped to now+60s by + `clampTs()` in `src/schema.ts`. +- **Terminal too narrow (<60 cols) or short (<12 rows).** App renders a + "terminal too small" screen instead of the broken layout. +- **500+ events in buffer.** Oldest drop off. For deeper history, drill + via projects → sessions (AUR-119/120/121). + +## Interactions + +- `Enter` opens [event detail](./event-detail.md) for the focused row. +- `/` opens [search](./search.md). +- `P` opens [projects navigation](./projects-nav.md). +- `p` opens [permissions](./permissions.md). +- `x` enters [subagent drilldown](./subagent-drilldown.md). +- `y` copies the row's most useful payload to the clipboard + (see [clipboard-yank](./clipboard-yank.md)). +- `space` pauses the live stream without clearing the buffer. diff --git a/docs/testing/TEST-SCRIPT.md b/docs/testing/TEST-SCRIPT.md new file mode 100644 index 0000000..e1c68d3 --- /dev/null +++ b/docs/testing/TEST-SCRIPT.md @@ -0,0 +1,178 @@ +# 15-minute pre-release test script + +Run before every tagged release. Walks through every feature once with +a real machine + real agent activity. + +## Prerequisites + +- Node ≥ 20 +- At least one AI coding agent installed (Claude Code preferred) +- A workspace with ≥3 projects under `~/IdeaProjects` (or whatever + `$WORKSPACE_ROOT` is set to) +- Clean clone of `agentwatch` + `npm install` +- Terminal ≥ 100 cols × 30 rows recommended + +## Steps + +### 1. `agentwatch doctor` (30s) + +```bash +npx tsx src/index.tsx doctor +``` + +**Expect:** +- Every installed agent shown with `●` + "installed (events captured)" +- Non-installed agents show `○` + "not detected" +- Detected-but-not-instrumented agents show `●` + yellow "detected + (events not yet captured — help us ship this)" +- Workspace path printed at top +- If any non-instrumented agents are present, the footer help-banner + appears with an issue link + +**Red flag:** a crash, a permission error, or any agent misclassified +(e.g. "installed" but not actually present). + +### 2. Launch TUI, observe backfill (1 min) + +```bash +npm run dev +``` + +**Expect:** +- Alt screen enters cleanly (previous terminal content hidden) +- Column header visible: `TIME · AGENT · TYPE · EVENT` +- Backfill events appear within ~2 seconds, ordered newest first +- Timeline shows ≤40 rows with proper risk coloring +- Right-side agent panel lists every detected agent with event counts + + cost + +**Red flag:** empty timeline after 5s if you have any session history; +events appearing in random order; panel showing wrong counts. + +### 3. Event detail pane (1 min) + +- `↓` once to select the top event +- `Enter` to open detail +- Scroll `↓↓↓` through the content +- Verify: tokens/cost/duration block, full text, tool input JSON, + tool result (if applicable), extended thinking (if applicable) +- `esc` to close + +**Red flag:** missing content for an event that should have it; +scroll not working; escape not closing. + +### 4. Full-text search (1 min) + +- `/` to open search +- Type `Bash` (or any common token) +- Verify: match count drops below total; filtered rows all contain the + token +- Backspace to edit the query +- `Enter` to confirm (breadcrumb shows `search "bash"`, input cursor gone) +- Type `/` again + new query — query replaces the old one +- `esc` clears + +**Red flag:** typing `q` while in search mode quitting the app (bug we +fixed in `confirm-search`); match count wrong; esc not clearing. + +### 5. Projects navigation (2 min) + +- `P` opens projects grid +- `↓↓↓` to move selection +- `Enter` on a project with multiple sessions +- Sessions list appears, bucketed by date +- `↓↓↓` to move +- `Enter` on a session +- Timeline is now scoped to that session (breadcrumb shows session + id8); only session events visible +- `esc` back to projects list +- `esc` back to main timeline +- `0` home — everything reset + +**Red flag:** selection jumping around unexpectedly; esc not walking +back one level; sessions list showing wrong agent tags. + +### 6. Subagent drilldown (1 min) + +- Look for a row with `▸ N child events` suffix (Claude Agent tool_use) +- Select it, press `x` +- Breadcrumb shows `sub <agentId8>` +- Timeline shows only that subagent's inner tool calls +- `X` to unscope + +**Red flag:** `x` silently doing nothing on a row that clearly has a +subAgentId (regex failure); wrong events appearing in the scoped view. + +### 7. Permissions view (1 min) + +- `p` opens the view +- Scroll `↓↓↓` through Claude section, Cursor section, OpenClaw section +- Verify flagged risks appear in red/yellow +- Pagination footer shows `N–M of total` +- `p` or `esc` closes + +**Red flag:** view too long to scroll; sections missing; flag labels +wrong. + +### 8. Clipboard yank (30s) + +- Select any event with a `cmd` or `toolResult` +- `y` +- Flash `✓ copied N chars to clipboard` appears briefly +- Paste in another app to verify content + +**Red flag:** `✗ EBADF` (stdio regression); wrong content pasted; no +flash at all. + +### 9. Desktop notifications (2 min) + +- In another terminal, run `echo foo > /tmp/test.env && rm /tmp/test.env` + (does NOT fire; path isn't `.env`) +- Actually trigger: use Claude Code, have it read a file literally named + `.env` in one of your projects +- Verify OS notification appears with `⚠ agentwatch — .env access` +- Wait 60s, trigger same action again — notification fires again +- Trigger the same action twice within 60s — second is rate-limited + +**Red flag:** notification fires on every backfill event (regression +on launchedAt gating); rate limit not working; `osascript` throwing +visible errors. + +### 10. Terminal-too-small (30s) + +- Resize terminal to <60 cols or <12 rows +- Verify the "terminal too small" screen appears with current dimensions +- Resize back — TUI returns (after brief re-mount) + +**Red flag:** broken layout instead of the friendly message. + +### 11. Help overlay (15s) + +- `?` opens the help overlay +- Every hotkey group visible +- `?` or `esc` closes + +**Red flag:** missing hotkeys; `esc` not closing. + +### 12. Graceful quit (10s) + +- `q` quits instantly +- Terminal scrollback restored (alt screen exits cleanly) +- Shell prompt returns + +**Red flag:** 2+ second delay on quit (chokidar close regression); +terminal stuck in raw mode; scrollback corrupted. + +## Sign-off + +If all 12 steps pass with zero red flags → proceed to tag + publish. + +If any red flag → file a Linear issue, block the release, fix + re-run +this script end-to-end. + +## Artifacts to keep per release + +- Screenshot of each view as of this release (for regression + comparison on the next release) +- `agentwatch doctor` output +- `npm pack --dry-run` output diff --git a/docs/testing/clipboard-yank.md b/docs/testing/clipboard-yank.md new file mode 100644 index 0000000..3890de6 --- /dev/null +++ b/docs/testing/clipboard-yank.md @@ -0,0 +1,40 @@ +# Testing: clipboard yank + +## Prerequisites + +- macOS: `pbcopy` (always installed) +- Linux: `wl-copy` OR `xclip` OR `xsel` — first-found wins +- Windows: `clip` (always installed) + +## Happy path + +1. Select any event with a `toolResult` (a Bash row works — select, + press `Enter` first to see its content, then `esc` back). +2. Press `y`. +3. Flash message `✓ copied N chars to clipboard` appears for 2s. +4. Paste in another app — content matches the event's tool_result. + +Per-event priority (`eventToYankText`): +- tool_result text if present +- else fullText +- else cmd +- else path +- else summary + +## Chaos tests + +1. **No clipboard tool installed (Linux minimal).** Flash shows + `✗ install wl-copy / xclip / xsel for clipboard support`. +2. **Yank on a `session_start` event.** Copies the cwd path (summary) + or placeholder. +3. **Yank a 256 KB tool_result** (our cap). Clipboard receives the full + truncated string (with `[N bytes truncated]` suffix). +4. **Press `y` with no selection.** Silent no-op — no flash, no + clipboard change. +5. **Spam `y` rapidly.** Every press copies + shows flash; 2s timers + just overlap. + +## Known limitations + +- Macro-recording-style "yank multiple events" not supported. Visual + selection of ranges isn't modeled yet. diff --git a/docs/testing/event-detail.md b/docs/testing/event-detail.md new file mode 100644 index 0000000..1b75392 --- /dev/null +++ b/docs/testing/event-detail.md @@ -0,0 +1,42 @@ +# Testing: event detail pane + +## Prerequisites + +- ≥1 assistant-turn event in the timeline (prompt or response) +- ≥1 tool_use event (preferably a Bash or Edit) + +## Happy path + +1. `↑↓` to select an event. +2. `Enter` to open detail. +3. Verify header line: time + agent + type + tool (if applicable). +4. For an assistant-turn event, verify "tokens / cost / duration" block + shows: `in=N cache_create=N cache_read=N out=N`, `cost: $X.XX + (claude-opus-4-6)`, and `duration: Nms` if paired with a tool_result. +5. For a prompt event, verify "text" heading + full prompt text wrapped + to terminal width. +6. For a tool_use event, verify "tool input" heading with JSON-pretty + arguments; and "tool result" heading with the flattened output. +7. Scroll with `↓↓↓` — pagination footer updates. +8. `esc` closes — returns to the same timeline view with the same + selection. + +## Chaos tests + +1. **Event with no details.** Select a `session_start` event or any + row where we didn't attach `details`. Detail pane shows "(no + additional content captured for this event)". +2. **Very long tool_result.** Select a Bash run that produced >256 KB + of stdout (e.g. a `cat` on a big file). Content is truncated with + `… [N bytes truncated]` at the end. +3. **Resize terminal while detail open.** The `viewportRows` / `cols` + recompute on every render; scroll offset clamps to the new max. +4. **Binary-content tool_result.** If stdout contained bytes that break + Unicode, verify we still render (may show replacement chars) and + don't crash. + +## Known limitations + +- No syntax highlighting yet (tracked as AUR-105, M5). +- No inline diff rendering for Edit tool_use — file content shown as + plain text. Diff view is tracked for v0.4. diff --git a/docs/testing/navigation.md b/docs/testing/navigation.md new file mode 100644 index 0000000..1a06ea8 --- /dev/null +++ b/docs/testing/navigation.md @@ -0,0 +1,50 @@ +# Testing: projects + sessions navigation + +## Prerequisites + +- ≥3 projects with ≥1 session each in your event buffer (if you're new + to agentwatch, use Claude Code in a few directories first to + populate). + +## Happy path + +1. `P` opens projects grid. +2. Header reads `Projects — N workspaces`. +3. Footer: `[↑↓] select project · [enter] sessions · [esc] close`. +4. `↓↓↓` — selected row marker moves. +5. Each card shows: name (padded), ago-string, cost (yellow), events + count, sessions count, per-agent breakdown `claude:22, openclaw:4`. +6. `Enter` on a project — Sessions view opens. +7. Sessions view header: `Sessions — <project> N sessions`. +8. Rows bucketed by heading: `TODAY`, `YESTERDAY`, `LAST 7 DAYS`, + `OLDER`. +9. Each session row: colored agent tag, first user prompt, event count, + ago, cost, `· ERR` suffix if any error. +10. `Enter` on a session — main timeline scopes to that session's + events only. Breadcrumb shows `session <id8>`. +11. `esc` from scoped timeline — returns to fresh timeline, scope + cleared. +12. `Z` from anywhere clears all filters (project, session, subagent, + agent, search). +13. `0` from anywhere — home reset (same as Z + close all modals). + +## Chaos tests + +1. **Zero projects** (new machine, no session history). Grid shows + "No projects yet. Use Claude Code / OpenClaw / Cursor and they'll + show up here as events stream in." +2. **Project with 200 sessions.** Sessions view scrolls; pagination + shows `N–M of total`. +3. **Session with no user prompt** (subagent-only activity). First- + prompt field shows "(no user prompt yet)". +4. **esc from sessions view.** Must return to projects grid, not jump + all the way out. +5. **Change agent filter while inside a scoped session.** Scope + persists; agent filter stacks on top. + +## Known limitations + +- Search `/` inside the projects view narrows projects by name. Same + `/` in sessions view narrows by prompt text. +- "Scoped timeline" doesn't persist across agentwatch restarts — no + bookmarks yet. diff --git a/docs/testing/notifications.md b/docs/testing/notifications.md new file mode 100644 index 0000000..51bef4a --- /dev/null +++ b/docs/testing/notifications.md @@ -0,0 +1,45 @@ +# Testing: desktop notifications + +## Prerequisites + +- macOS: notifications allowed for Terminal.app / iTerm / WezTerm in + System Settings +- Linux: `notify-send` on PATH and a running notification daemon + (dunst, notify-osd, etc.) +- Windows: PowerShell available + +## Happy path (macOS example) + +1. Launch TUI: `npm run dev`. +2. In another terminal, have Claude Code read a file literally named + `.env` in one of your projects. +3. OS notification appears within ~1 second: `⚠ agentwatch — .env access`. +4. Body contains the agent name, event type, and path. +5. Wait 60 seconds. +6. Repeat the same action — notification fires again. +7. Repeat the same action twice within 60 seconds — only the first + fires (rate limiter). + +## Rules verified + +- `.env` read or write. +- `~/.ssh`, `~/.aws`, `~/.gnupg` touches. +- `rm -rf`, `sudo`, `curl | sh` in shell_exec. +- Tool errors (`is_error: true`). + +## Chaos tests + +1. **Disable notifications at OS level.** Action still fires — we catch + the error silently. No stderr spam. +2. **Uninstall `notify-send` on Linux** (if applicable). First call + throws ENOENT; notifier self-disables for the session — no spam. +3. **Trigger a rule at launch time** (backfill). Must not fire — only + events with `ts >= launchedAt` trigger notifications. +4. **Rapid-fire 10 alerts of the same kind.** Only the first fires; + next 9 are gated out by the 60s rate-limit keyed by rule + path/cmd. + +## Known limitations + +- User-defined rules not supported until v0.5 (AUR-108). +- No per-agent toggling — if any agent reads `.env`, it fires. +- No "snooze for the next hour" — restart agentwatch to reset. diff --git a/docs/testing/permissions.md b/docs/testing/permissions.md new file mode 100644 index 0000000..f7b46e0 --- /dev/null +++ b/docs/testing/permissions.md @@ -0,0 +1,48 @@ +# Testing: permissions view + +## Prerequisites + +- Claude Code installed with a populated `~/.claude/settings.json` +- Ideally OpenClaw installed to test that section +- Ideally Cursor installed to test that section + +## Happy path + +1. `p` opens the permissions view. +2. Verify header: `Permissions / Configuration across installed agents`. +3. Claude section (cyan title) shows: + - `source: /Users/…/.claude/settings.json` + - `defaultMode:` with color (red if `auto`/`bypassPermissions`) + - `CAN (N)` block with ✓-prefixed rows + - `CANNOT (N)` block with ✗-prefixed rows +4. If `Bash(*)` is in allow, verify yellow `⚠ Flags` section with red + `✗ Bash(*) allows arbitrary shell…` line. +5. Cursor section (magenta) shows MCP server list + approval mode + + sandbox. +6. OpenClaw section (yellow) shows default workspace + each sub-agent + with model + workspace. +7. Pagination footer shows `N–M of total`. `↓↓↓` scrolls; `↑↑↑` scrolls + back. +8. `p` or `esc` closes → returns to the previous view. + +## Chaos tests + +1. **Malformed settings.json.** Temporarily write `{` to the file. + Permissions view should show "No settings.json found." or a + source-line with the path but empty allow/deny (never a crash). +2. **No settings.json.** Move the file aside. Section shows "No + settings.json found.". +3. **OpenClaw / Cursor not installed.** Those sections show "not + detected". +4. **Terminal < 20 rows.** Scroll works — entire view shown via + pagination. +5. **Very long deny list (100+ entries).** Scroll should remain smooth; + pagination accurate. + +## Known limitations + +- Gemini CLI section intentionally omitted (Gemini exposes no + permission model beyond auth). +- Codex / Aider / Cline permissions not yet shown — comes when those + adapters ship. +- No "what if I change this" simulator. View is read-only. diff --git a/docs/testing/search.md b/docs/testing/search.md new file mode 100644 index 0000000..f250cd1 --- /dev/null +++ b/docs/testing/search.md @@ -0,0 +1,33 @@ +# Testing: search + +## Happy path + +1. `/` opens search — yellow `/` prompt appears under the timeline with a + blinking cursor. +2. Type `Bash` — every keystroke narrows the timeline. +3. Below the timeline: `matches: N` shows live count. +4. Backspace — cursor edits query, matches widen. +5. `Enter` — input mode exits, cursor disappears, query persists as + sticky filter. Breadcrumb shows `search "Bash"`. +6. Type `/` again + new query — replaces previous. +7. `esc` clears query and exits mode. + +## Chaos tests + +1. **Type `q` while in search mode.** Must stay in search (not quit). +2. **Regex-special characters (`.` `*` `[`) in query.** Treated as + literals — no regex matching. +3. **Empty query after backspacing all chars.** Filter effectively + disabled; all events visible. +4. **Query that matches nothing.** Timeline shows empty body, + `matches: 0` visible. +5. **Query across 10k+ events.** Still instant (in-memory). +6. **Navigate with ↑↓ while search open.** Should stay in search input + mode — ↑↓ not consumed by timeline. + +## Known limitations + +- No regex support in v0 (planned v0.5 via custom triggers). +- No highlighting of matched substring within rows (only whole-row + filtering). +- In-buffer only. Cross-session disk search is AUR-111, v0.5. diff --git a/docs/testing/subagent-drilldown.md b/docs/testing/subagent-drilldown.md new file mode 100644 index 0000000..a512025 --- /dev/null +++ b/docs/testing/subagent-drilldown.md @@ -0,0 +1,41 @@ +# Testing: subagent drilldown + +## Prerequisites + +- ≥1 Claude Code session where you used the `Task` / Agent tool + (spawning a subagent). Look for a row in the timeline tagged + `Agent: <description>` with `▸ N child events` suffix. + +## Happy path + +1. Select an Agent event. +2. Press `x`. +3. Breadcrumb shows `sub <agentId8>`. +4. Timeline shows only events whose `sessionId === agent-<agentId>` or + whose `details.subAgentId === <agentId>`. +5. Scroll through — the subagent's Bash/WebFetch/Grep calls, prompts, + responses. +6. `Enter` on any child event — detail pane works normally. +7. `y` to yank a child event — clipboard works normally. +8. `X` to unscope — returns to full timeline, prior filters preserved. + +## Chaos tests + +1. **Press `x` on a non-Agent event.** Silent no-op (only works on + events whose `details.subAgentId` is set). +2. **Press `x` with nothing selected.** Silent no-op. +3. **Parent's tool_result never arrived** (session crashed). Subagent + events still ingested from their own jsonl; drilldown just can't + activate from the parent row. The child events are still visible + in the main timeline. +4. **Agent tool_use with description longer than 100 chars.** Summary + truncated; `▸ N child events` suffix still visible. + +## Known limitations + +- Only Claude Code subagents supported today. OpenClaw sub-agents are + modeled differently (each is its own session) and drilled via the + Sessions view, not `x`. +- subAgentId extracted by regex from tool_result text. If Claude's + future format drops the `agentId` string, drilldown breaks until + we update the extractor. diff --git a/docs/testing/timeline.md b/docs/testing/timeline.md new file mode 100644 index 0000000..52cf396 --- /dev/null +++ b/docs/testing/timeline.md @@ -0,0 +1,49 @@ +# Testing: timeline + +## Prerequisites + +- Claude Code installed and with ≥1 session in history +- TUI launched via `npm run dev` + +## Happy path + +1. After launch, wait 3 seconds — backfill should have events on screen. +2. Verify the column header reads `TIME · AGENT · TYPE · EVENT` and + stays pinned as events scroll. +3. In another terminal, run `claude` and type a short prompt. +4. Within 2 seconds, a new `prompt` event with a fresh timestamp appears + at the top of the timeline (reverse-chrono). +5. Claude responds — `response` event appears. +6. If Claude executes tools, those rows show `Bash: …` / `Read: …` with + proper risk colors. + +**Pass criteria:** + +- No missed live events +- Events consistently ordered by timestamp, newest first +- Rows never wrap — each is exactly one line, truncated with `…` + +## Chaos tests + +1. **Corrupt a session JSONL mid-stream.** Append `not valid json\n` to + an active `~/.claude/projects/<proj>/<session>.jsonl`. The adapter + should skip the malformed line and keep parsing later lines. + +2. **Future-dated timestamp.** Append a line with `"timestamp": + "2099-01-01T00:00:00.000Z"`. Event should appear, but not pinned to + the top of the list (clamp clamps it to now+60s max). + +3. **10,000 events in a single session.** Run a long Claude session. + Backfill should complete in <3 seconds. Buffer should cap at 500 + events — oldest fall off the tail. + +4. **File truncated to zero bytes.** `> ~/.claude/projects/.../x.jsonl` + (empty file). Adapter should not crash. New events appended later + are picked up. + +## Known limitations + +- Event buffer cap of 500 means older events drop. Drill into specific + sessions via `P` → project → session for full history. +- Backfill reads the last 64 KB of each session file. Very long sessions + won't surface their earliest events in the main timeline. diff --git a/docs/timeline.png b/docs/timeline.png new file mode 100644 index 0000000..f330e86 Binary files /dev/null and b/docs/timeline.png differ diff --git a/docs/use-cases/cost-overrun-investigation.md b/docs/use-cases/cost-overrun-investigation.md new file mode 100644 index 0000000..83c7205 --- /dev/null +++ b/docs/use-cases/cost-overrun-investigation.md @@ -0,0 +1,38 @@ +# Use case: Daily cost tripled yesterday. Why? + +**Scenario.** Your Claude Max / API spend jumped from ~$4/day to $12/day. +You want the session responsible, not a wild guess. + +## With agentwatch + +1. `agentwatch` +2. `P` → projects grid +3. Cards sorted by `lastTs`; the one with a suspiciously high yellow + cost is obvious at a glance +4. `Enter` into that project +5. Yesterday's bucket — skim the sessions list, one row per session + with cost per session +6. Spot the outlier (e.g. `[claude-code] "refactor the whole ingest + pipeline" 8,214 events · 4h · $9.40`) +7. `Enter` — scoped timeline for that session +8. Flip through the detail pane of a handful of tool calls — check the + `tokens / cost / duration` block +9. Discover: one bash ran `cat huge.log` producing 40 MB of tool + output, each turn now sends that back as context — cache creates + explode, cost spikes +10. Note the finding in your CHANGELOG / Linear + +## What agentwatch is doing + +- Per-agent + per-session cost uses cache-aware accounting. Naive tools + that treat `cache_read_input_tokens` at full rate would report ~3-5× + too high and still miss the real outlier because the absolute numbers + all look crazy. +- Tool-result capping (256 KB) means the timeline buffer doesn't get + blown up re-reading the 40 MB stdout; the detail pane shows + `[N bytes truncated]`. + +## Without agentwatch + +Open Anthropic dashboard → aggregate numbers, no per-session drilldown. +Guess which session. `jq` through JSONL files. Give up. diff --git a/docs/use-cases/env-leak-alert.md b/docs/use-cases/env-leak-alert.md new file mode 100644 index 0000000..2062e8a --- /dev/null +++ b/docs/use-cases/env-leak-alert.md @@ -0,0 +1,44 @@ +# Use case: "Wait, did the agent just read my .env?" + +**Scenario.** You're running Claude Code, focused on something unrelated. +A notification appears: + +``` +⚠ agentwatch — .env access +claude-code file_read /Users/you/IdeaProjects/prod-deployer/.env +``` + +## With agentwatch + +1. Notification fires within ~1 second of Claude reading the file. + Rate-limited so a legitimate multi-step task only fires once. +2. Open the agentwatch TUI (or focus the one you already had running). +3. `/` → type `.env` → scoped to the events touching `.env` files. +4. `Enter` on the `file_read` → detail pane: + - What prompt led to it (walk back in the same session) + - What the tool_result (file content) was — now in Claude's context +5. Decide: + - If innocent (agent read it to parse env vars for a legitimate + task) → OK + - If surprising (agent shouldn't have, or you didn't ask for this) + → rotate any sensitive secrets immediately and dig into the + session for more + +## Why agentwatch flags this + +Hardcoded rule in `src/util/notifier.ts`: +- Path matches `(^|/)\.env($|\.)` → notify +- Key: the full path (so re-reads of the same `.env` within 60s are + rate-limited but a second `.env` in a different project still fires) + +Additional paths flagged the same way: +- `~/.ssh`, `~/.aws`, `~/.gnupg` + +## Caveats + +- Doesn't *block* the read. agentwatch is read-only; see + [DashClaw](https://github.com/ucsandman/DashClaw) / + [Castra](https://github.com/amangsingh/castra) for pre-execution + policy enforcement. +- Rules are hardcoded in v0.3. User-defined regex triggers ship in + v0.5 (AUR-108). diff --git a/docs/use-cases/multi-agent-triage.md b/docs/use-cases/multi-agent-triage.md new file mode 100644 index 0000000..223a8d8 --- /dev/null +++ b/docs/use-cases/multi-agent-triage.md @@ -0,0 +1,37 @@ +# Use case: Who touched this project today? + +**Scenario.** You're running Claude Code, Cursor, and OpenClaw on the +`auraqu` monorepo. Tests are failing. You want to know: which agent +wrote which file, in what order, over the last few hours. + +## With agentwatch + +1. Launch: `agentwatch` +2. `P` → projects grid +3. Select `auraqu`, `Enter` +4. Sessions list for auraqu, bucketed by date +5. Glance at today's bucket — multiple sessions tagged + `[claude-code]`, `[openclaw:content]`, `[cursor]` +6. `↓` to the session whose first prompt hints at the failure you're + debugging +7. `Enter` — timeline scopes to just that session +8. `/` → type `src/auth` — filtered to events touching that path +9. `Enter` on a `file_write` row → full diff +10. `y` to yank the diff into your PR description + +## Without agentwatch + +Open three terminals. `tail -f ~/.claude/projects/<escaped>/<session>.jsonl`, +`tail -f ~/.openclaw/agents/content/sessions/<id>.jsonl`, `grep` Cursor's +SQLite… `jq` through JSONL to find timestamps ~90 minutes ago… +cross-reference `git log --since` output… give up and just `git bisect`. + +## What the TUI shows + +- Breadcrumb at top: `agentwatch · auraqu · session ab3c99 · search "src/auth"` +- Footer hint: `[?] help [esc] back [y] yank [p] permissions` +- Per-session cost in the sessions list: `$0.14 · 12 events · 3m ago` + +## Keys used + +`P`, `↓`, `Enter`, `/`, `Enter`, `y`, `esc`. diff --git a/docs/use-cases/security-audit.md b/docs/use-cases/security-audit.md new file mode 100644 index 0000000..b2afeaa --- /dev/null +++ b/docs/use-cases/security-audit.md @@ -0,0 +1,50 @@ +# Use case: What are my agents actually allowed to do? + +**Scenario.** You're about to give a teammate access to your Claude Max +plan. Or you're onboarding to a new project where the previous +developer set up Cursor + OpenClaw + Claude Code. What's the blast +radius today? + +## With agentwatch + +1. `agentwatch` +2. `p` → permissions view +3. Scroll. For each installed agent: + + **Claude Code** + - `defaultMode: auto` (red) — any tool not in allow/deny auto-runs + - Flags: `⚠ Bash(*) allows arbitrary shell — any command not + explicitly denied will run` + - Flags: `! Write/Edit allowed with no deny rule for ~/.ssh, + ~/.aws, ~/.gnupg` + + **Cursor** + - `approvalMode: allowlist` (green) — tighter than Claude's auto + - `sandbox: disabled` (red) — shell still can touch the host FS + - MCP servers: 1 (context7) + - `.cursorrules` discovered: 0 + + **OpenClaw** + - Default workspace: `/Users/.../auraqu/_content_agent_` + - 3 sub-agents: Quill (content, gemini-3.1-pro), Scout (outreach), + Yena (research) + - Note: OpenClaw runs with broad shell + file access per agent + +4. Screenshot for your notes +5. Decide which denies to add; edit `~/.claude/settings.json` manually + (agentwatch is read-only) +6. `p` again — confirm the flagged risks disappeared + +## What agentwatch is doing + +- Parsing three different permission models into one view. +- Flagging patterns without opinion: doesn't prevent you from + running `Bash(*)`, just surfaces that you are. +- Gemini CLI section omitted — genuinely exposes no permission model + beyond auth, so we document that instead of faking a section. + +## Without agentwatch + +Open three config files. Read each one with different syntax (JSON for +Claude, TOML for Cursor's config, JSON for OpenClaw). Hope you +remember what each field means. diff --git a/docs/use-cases/stuck-loop-detection.md b/docs/use-cases/stuck-loop-detection.md new file mode 100644 index 0000000..2cefd0b --- /dev/null +++ b/docs/use-cases/stuck-loop-detection.md @@ -0,0 +1,43 @@ +# Use case: Catch a stuck loop before it eats your API budget + +**Scenario.** Claude gets stuck retrying the same failing Bash command +15 times. You're in a different window and don't notice for 20 minutes. +By then: 40 minutes of wasted session, $4 spent repeating the same +error. + +## With agentwatch today + +Not fully automated in v0.3 — but you'd see: + +1. Timeline scrolls with a stream of `shell_exec` events tagged + `[proj] Bash: npm test` repeating +2. If the command has `rm -rf` or `sudo`, a desktop notification fires + once (rate-limited). For normal-looking but pathological commands + (looping npm test), no alert today. +3. Per-session cost in the agent side panel climbs rapidly — visible + if you glance back at the TUI + +## How agentwatch v1.0 will make this automatic + +Tracked as AUR-117 (anomaly detection): +- Stuck-loop detector: same tool_use + same args ≥5 times in 60s → + notification +- Cost-spike detector: session's cost-per-turn 3× the 7-day median + for the project → notification +- Error-burst detector: ≥3 tool errors in 2 minutes → notification + +## Today's workaround + +1. Set `~/.agentwatch/triggers.json` (in v0.5) with + ``` + [{ "name": "loop", "match": { "cmd": "npm test" }, + "notify": "desktop" }] + ``` + For v0.3, custom triggers aren't configurable — only the hardcoded + destructive-pattern rules fire. +2. Run agentwatch in a dedicated tmux pane always-visible. +3. Watch the cost counter. + +## Without agentwatch + +Discover the spent $4 on the Anthropic dashboard next morning. diff --git a/docs/use-cases/subagent-postmortem.md b/docs/use-cases/subagent-postmortem.md new file mode 100644 index 0000000..c756f93 --- /dev/null +++ b/docs/use-cases/subagent-postmortem.md @@ -0,0 +1,40 @@ +# Use case: Why did my research subagent take 6 minutes? + +**Scenario.** You spawned a Claude `Task` to do web research. It came +back with a mediocre answer after 6 minutes and $0.08 in spend. You +want to know what it actually did — every inner tool call, every +attempted fetch, how much time each took. + +## With agentwatch + +1. `agentwatch` +2. In the timeline, find the parent Agent event. It shows: + ``` + 10:13:25 claude-code tool_call [auraqu] Agent: Multi-agent dev pain research ▸ 52 child events + ``` +3. Select it. Press `x` → timeline scopes to the subagent's inner run. +4. Breadcrumb: `sub ab3c99fc`. +5. 52 events visible: Bash (curl), WebFetch, Grep, Read, prompts to + itself, responses. Each row shows duration (`· 151ms · 3.2s · ERR`). +6. Browse the list — notice 8 WebFetch calls failed with `· ERR` + against the same unreachable domain. The subagent retried instead + of pivoting. +7. `Enter` on one of the failed WebFetches — detail pane shows the + URL, the error message, the duration. +8. Understand: the failure mode was DNS, not the research question. +9. `X` to unscope. + +## What agentwatch is doing + +- Ingesting `~/.claude/projects/<session>/subagents/agent-<id>.jsonl` + (otherwise invisible). +- Regex-extracting `agentId` from the parent's tool_result, linking + parent row to subagent events. +- Pairing each `tool_use` with its `tool_result` for duration + content + + error flag. +- Aggregating total duration and event count onto the parent row. + +## Without agentwatch + +Grep `~/.claude/projects/.../<session>/subagents/` by hand. No +duration info, no error attribution. diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..2a9908e --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["mishanefedov"] +} diff --git a/package-lock.json b/package-lock.json index f268656..efa15e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,64 @@ { "name": "@misha_misha/agentwatch", - "version": "0.0.1", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@misha_misha/agentwatch", - "version": "0.0.1", + "version": "0.0.3", "license": "MIT", "os": [ "darwin", "linux" ], "dependencies": { + "@fastify/static": "^9.1.1", + "@huggingface/transformers": "^4.1.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-node": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "better-sqlite3": "^12.9.0", "chokidar": "^4.0.3", + "cli-highlight": "^2.1.11", + "fastify": "^5.8.5", + "gpt-tokenizer": "^3.4.0", "ink": "^5.1.0", - "react": "^18.3.1" + "react": "^18.3.1", + "zod": "^4.3.6" }, "bin": { "agentwatch": "bin/agentwatch.js" }, "devDependencies": { + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "^1.168.22", + "@types/better-sqlite3": "^7.6.13", + "@types/d3-hierarchy": "^3.1.7", "@types/node": "^22.10.2", "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "clsx": "^2.1.1", + "d3-hierarchy": "^3.1.2", + "lucide-react": "^1.8.0", + "postcss": "^8.5.10", + "react-diff-viewer-continued": "^4.2.0", + "react-dom": "^18.3.1", + "react-is": "^19.2.5", + "react-router-dom": "^6.30.3", + "recharts": "^3.8.1", + "tailwindcss": "^3.4.19", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "vite": "^8.0.8", + "vitest": "^2.1.8", + "zustand": "^5.0.12" }, "engines": { "node": ">=20" @@ -45,6 +77,364 @@ "node": ">=14.13.1" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "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", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -487,1364 +877,8313 @@ "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", - "engines": { - "node": ">=6.0.0" + "dependencies": { + "fast-json-stringify": "^6.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "dequal": "^2.0.3" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "node_modules/@fastify/static": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.1.tgz", + "integrity": "sha512-LHxFea3qdwe0Pbbkh/yux7/k6nFNLGTNcbLKVYgmRDB6LdDE/8TFSO7qWZ0IzM/nF6iwR8W03oFlwe4v79R1Ow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@grpc/proto-loader/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", + "integrity": "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz", + "integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", + "sharp": "^0.34.5" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" - ] + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" - ] + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ - "arm" + "arm64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ - "arm64" + "ppc64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ - "arm64" + "riscv64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ - "loong64" + "s390x" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ - "loong64" + "x64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ - "ppc64" + "arm64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ - "ppc64" + "x64" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ - "riscv64" + "arm" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ - "riscv64" + "arm64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ - "s390x" + "ppc64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ - "x64" + "riscv64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ - "x64" + "s390x" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" - ] + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openharmony" - ] + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ - "arm64" + "x64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" - ] + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ - "ia32" + "wasm32" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ - "x64" + "ia32" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=18" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { - "msw": { + "@cfworker/json-schema": { "optional": true }, - "vite": { - "optional": true + "zod": { + "optional": false } } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "tinyrainbow": "^1.2.0" + "@tybys/wasm-util": "^0.10.1" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 8" } }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 8" } }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 8" } }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=0.4.0" + "node": ">=8.0.0" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", + "node_modules/@opentelemetry/configuration": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.214.0.tgz", + "integrity": "sha512-Q+awuEwxhETwIAXuxHvIY5ZMEP0ZqvxLTi9kclrkyVJppEUXYL3Bhiw3jYrxdHYMh0Y0tVInQH9FEZ1aMinvLA==", + "license": "Apache-2.0", "dependencies": { - "environment": "^1.0.0" + "@opentelemetry/core": "2.6.1", + "yaml": "^2.0.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", + "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", + "node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=12" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "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", + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.214.0.tgz", + "integrity": "sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/sdk-logs": "0.214.0" + }, "engines": { - "node": ">=12" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.214.0.tgz", + "integrity": "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/sdk-logs": "0.214.0" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.214.0.tgz", + "integrity": "sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==", + "license": "Apache-2.0", "dependencies": { - "load-tsconfig": "^0.2.3" + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-trace-base": "2.6.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "esbuild": ">=0.18" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.214.0.tgz", + "integrity": "sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" + }, "engines": { - "node": ">=8" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", + "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", + "license": "Apache-2.0", "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.214.0.tgz", + "integrity": "sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.214.0.tgz", + "integrity": "sha512-4TGYoZKebUWVuYkV6r5wS2dUF4zH7EbWFw/Uqz1ZM1tGHQeFT9wzHGXq3iSIXMUrwu5jRdxjfMaXrYejPu2kpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">= 16" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.214.0.tgz", + "integrity": "sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==", + "license": "Apache-2.0", "dependencies": { - "readdirp": "^4.0.1" + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1" }, "engines": { - "node": ">= 14.16.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz", + "integrity": "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1" + }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.214.0.tgz", + "integrity": "sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==", + "license": "Apache-2.0", "dependencies": { - "restore-cursor": "^4.0.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.6.1.tgz", + "integrity": "sha512-km2/hD3inLTqtLnUAHDGz7ZP/VOyZNslrC/iN66x4jkmpckwlONW54LRPNI6fm09/musDtZga9EWsxgwnjGUlw==", + "license": "Apache-2.0", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" } }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=12" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", + "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "license": "Apache-2.0", "dependencies": { - "convert-to-spaces": "^2.0.1" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-transformer": "0.214.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.214.0.tgz", + "integrity": "sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0" + }, "engines": { - "node": ">= 6" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", + "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "protobufjs": "^7.0.0" + }, "engines": { - "node": "^14.18.0 || >=16.10.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", + "node_modules/@opentelemetry/propagator-b3": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.6.1.tgz", + "integrity": "sha512-Dvz9TA6cPqIbxolSzQ5x9br6iQlqdGhVYrm+oYc7pfJ7LaVXz8F0XIqhWbnKB5YvfZ6SUmabBUUxnvHs/9uhxA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.6.1.tgz", + "integrity": "sha512-kKFMxBcjBZAC1vBch5mtZ/dJQvcAEKWga+c+q5iGgRLPIE6Mc649zEwMaCIQCzalziMJQiyUadFYMHmELB7AFw==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "@opentelemetry/core": "2.6.1" }, "engines": { - "node": ">=6.0" + "node": "^18.19.0 || >=20.6.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=6" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", + "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-node": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.214.0.tgz", + "integrity": "sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/configuration": "0.214.0", + "@opentelemetry/context-async-hooks": "2.6.1", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-logs-otlp-grpc": "0.214.0", + "@opentelemetry/exporter-logs-otlp-http": "0.214.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.214.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.214.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.214.0", + "@opentelemetry/exporter-prometheus": "0.214.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", + "@opentelemetry/exporter-zipkin": "2.6.1", + "@opentelemetry/instrumentation": "0.214.0", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/propagator-b3": "2.6.1", + "@opentelemetry/propagator-jaeger": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=8" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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", + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "license": "Apache-2.0", "dependencies": { - "@types/estree": "^1.0.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.6.1.tgz", + "integrity": "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.6.1", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1" + }, "engines": { - "node": ">=12.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "react": { + "optional": true + }, + "react-redux": { "optional": true } } }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "dev": true, "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", - "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "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/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "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": "^20.19.0 || >=22.12.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "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==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=14.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">= 14.16" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">= 6" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz", + "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", + "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.168.22", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.22.tgz", + "integrity": "sha512-W2LyfkfJtDCf//jOjZeUBWwOVl8iDRVTECpGHa2M28MT3T5/VVnjgicYNHR/ax0Filk1iU67MRjcjHheTYvK1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.168.15", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.168.15", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.15.tgz", + "integrity": "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^3.0.0", + "seroval": "^1.5.0", + "seroval-plugins": "^1.5.0" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true, + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "license": "MIT" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.1.39", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.39.tgz", + "integrity": "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "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": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "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/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": "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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260410-5e55544225", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260410-5e55544225.tgz", + "integrity": "sha512-hHd9n8DzIfGSAjM4Dvslesc8i6h9HEEcl8qt7X3LfhUxMgls6FBJ32j2xrDtJjKJFEehFeJmyB/pvad1I8KS8w==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-diff-viewer-continued": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.0.tgz", + "integrity": "sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "classnames": "^2.5.1", + "diff": "^8.0.3", + "js-yaml": "^4.1.1", + "memoize-one": "^6.0.0" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/seroval": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", + "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz", + "integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "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==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "url": "https://tidelift.com/funding/github/npm/browserslist" }, { "type": "github", @@ -1853,47 +9192,132 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.8" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dev": true, + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">= 18" + "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.1.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "postcss": ">=8.0.9", + "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 }, - "postcss": { + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true }, "tsx": { @@ -1904,497 +9328,467 @@ } } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" }, - "engines": { - "node": ">=0.10.0" + "bin": { + "vite-node": "vite-node.mjs" }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://opencollective.com/vitest" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" + "node": ">=12" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=12" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 12" + "node": ">=12" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=10" + "node": ">=12" } }, - "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==", + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.8" + "node": ">=12" } }, - "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": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=12" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=14.0.0" + "node": ">=12" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=14.0.0" + "node": ">=12" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } + "node": ">=12" } }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": ">=12" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, - "license": "Apache-2.0", + "hasInstallScript": true, + "license": "MIT", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=14.17" + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true, "license": "MIT" }, - "node_modules/vite": { + "node_modules/vite-node/node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", @@ -2454,37 +9848,73 @@ } } }, - "node_modules/vite-node": { + "node_modules/vitest": { "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^6.7.14", + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", "pathe": "^1.1.2", - "vite": "^5.0.0" + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite-node": "vite-node.mjs" + "vitest": "vitest.mjs" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", @@ -2501,7 +9931,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { + "node_modules/vitest/node_modules/@esbuild/android-arm": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", @@ -2518,7 +9948,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { + "node_modules/vitest/node_modules/@esbuild/android-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", @@ -2535,7 +9965,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { + "node_modules/vitest/node_modules/@esbuild/android-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", @@ -2552,7 +9982,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", @@ -2569,7 +9999,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", @@ -2586,7 +10016,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", @@ -2603,7 +10033,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", @@ -2620,7 +10050,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { + "node_modules/vitest/node_modules/@esbuild/linux-arm": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", @@ -2637,7 +10067,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", @@ -2654,7 +10084,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", @@ -2671,7 +10101,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", @@ -2688,7 +10118,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", @@ -2705,7 +10135,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", @@ -2722,7 +10152,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", @@ -2739,7 +10169,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", @@ -2756,7 +10186,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { + "node_modules/vitest/node_modules/@esbuild/linux-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", @@ -2773,7 +10203,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", @@ -2790,7 +10220,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", @@ -2807,7 +10237,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", @@ -2824,7 +10254,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", @@ -2841,7 +10271,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", @@ -2858,7 +10288,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { + "node_modules/vitest/node_modules/@esbuild/win32-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", @@ -2875,7 +10305,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/esbuild": { + "node_modules/vitest/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", @@ -2914,78 +10344,87 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@types/node": { + "less": { "optional": true }, - "@vitest/browser": { + "lightningcss": { "optional": true }, - "@vitest/ui": { + "sass": { "optional": true }, - "happy-dom": { + "sass-embedded": { "optional": true }, - "jsdom": { + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true } } }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } }, "node_modules/why-is-node-running": { "version": "2.3.0", @@ -3036,6 +10475,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -3057,11 +10502,160 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 3a8144e..61b9b4b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@misha_misha/agentwatch", - "version": "0.0.1", - "description": "Local-only observability for AI coding agents. See what Claude, Codex, Cursor, Gemini, and OpenClaw are touching on your machine — in one timeline.", + "version": "0.1.0", + "description": "Local-only observability + control plane for every AI coding agent on your machine. TUI live tail + browser dashboard on localhost. Unified timeline across Claude Code, Codex, Gemini CLI, Cursor, Hermes, OpenClaw — token + cost accounting, compaction + anomaly detection, SVG call graphs, diff attribution, agent-aware replay, MCP server mode, OpenTelemetry exporter. No cloud, no telemetry, no sign-in.", "type": "module", "author": "Misha Nefedov", "repository": { @@ -22,8 +22,11 @@ "LICENSE" ], "scripts": { - "build": "tsup", + "build": "npm run build:server && npm run build:web", + "build:server": "tsup", + "build:web": "cd web && vite build", "dev": "tsx src/index.tsx", + "dev:web": "cd web && vite", "start": "node bin/agentwatch.js", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -44,17 +47,49 @@ ], "license": "MIT", "dependencies": { + "@fastify/static": "^9.1.1", + "@huggingface/transformers": "^4.1.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-node": "^0.214.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "better-sqlite3": "^12.9.0", "chokidar": "^4.0.3", + "cli-highlight": "^2.1.11", + "fastify": "^5.8.5", + "gpt-tokenizer": "^3.4.0", "ink": "^5.1.0", - "react": "^18.3.1" + "react": "^18.3.1", + "zod": "^4.3.6" }, "devDependencies": { + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "^1.168.22", + "@types/better-sqlite3": "^7.6.13", + "@types/d3-hierarchy": "^3.1.7", "@types/node": "^22.10.2", "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "clsx": "^2.1.1", + "d3-hierarchy": "^3.1.2", + "lucide-react": "^1.8.0", + "postcss": "^8.5.10", + "react-diff-viewer-continued": "^4.2.0", + "react-dom": "^18.3.1", + "react-is": "^19.2.5", + "react-router-dom": "^6.30.3", + "recharts": "^3.8.1", + "tailwindcss": "^3.4.19", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "vite": "^8.0.8", + "vitest": "^2.1.8", + "zustand": "^5.0.12" }, "engines": { "node": ">=20" diff --git a/src/adapters/claude-code.compaction.test.ts b/src/adapters/claude-code.compaction.test.ts new file mode 100644 index 0000000..d1b40cd --- /dev/null +++ b/src/adapters/claude-code.compaction.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { translateClaudeLine } from "./claude-code.js"; + +describe("translateClaudeLine — compaction detection", () => { + it("emits a compaction event when isCompactSummary=true", () => { + const e = translateClaudeLine( + { + type: "user", + isCompactSummary: true, + timestamp: "2026-04-15T10:00:00Z", + message: { + role: "user", + content: + "This session is being continued from a previous conversation that ran out of context. Summary: …", + }, + }, + "sess-1", + "myproj", + ); + expect(e?.type).toBe("compaction"); + expect(e?.summary).toContain("⋈ context compacted"); + expect(e?.summary).toContain("[myproj]"); + }); + + it("still emits a normal prompt when isCompactSummary is absent", () => { + const e = translateClaudeLine( + { + type: "user", + timestamp: "2026-04-15T10:00:00Z", + message: { role: "user", content: "hi" }, + }, + "sess-1", + "myproj", + ); + expect(e?.type).toBe("prompt"); + }); +}); diff --git a/src/adapters/claude-code.test.ts b/src/adapters/claude-code.test.ts index f4e802d..06ab25d 100644 --- a/src/adapters/claude-code.test.ts +++ b/src/adapters/claude-code.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; +import { + appendFileSync, + mkdirSync, + mkdtempSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { translateClaudeLine } from "./claude-code.js"; +import { readNewlineTerminatedLines } from "../util/jsonl-stream.js"; describe("translateClaudeLine", () => { it("emits a prompt event for a user message", () => { @@ -15,7 +25,7 @@ describe("translateClaudeLine", () => { expect(e?.summary).toContain("help me debug"); }); - it("emits a tool_call with elevated risk for a Bash tool_use", () => { + it("emits shell_exec with elevated risk for a Bash tool_use", () => { const line = { type: "assistant", timestamp: "2026-04-14T10:00:01.000Z", @@ -27,8 +37,21 @@ describe("translateClaudeLine", () => { }, }; const e = translateClaudeLine(line, "sess-1"); - expect(e?.type).toBe("tool_call"); + expect(e?.type).toBe("shell_exec"); expect(e?.agent).toBe("claude-code"); + expect(e?.tool).toBe("Bash"); + expect(e?.cmd).toBe("rm -rf /tmp/x"); + expect(e?.summary).toContain("Bash: rm -rf /tmp/x"); + expect(e?.riskScore).toBeGreaterThanOrEqual(9); + }); + + it("suppresses empty assistant messages", () => { + const line = { + type: "assistant", + timestamp: "2026-04-14T10:00:05.000Z", + message: { role: "assistant", content: [] }, + }; + expect(translateClaudeLine(line, "sess-1")).toBeNull(); }); it("emits a response for an assistant text message", () => { @@ -50,3 +73,61 @@ describe("translateClaudeLine", () => { expect(translateClaudeLine({ type: "summary" }, "sess-1")).toBeNull(); }); }); + +describe("claude-code adapter — partial-line streaming (AUR-227)", () => { + it("recovers a JSONL line that was flushed across two reads", () => { + // Reproduces the bug: producer writes the first half of a JSON line, + // we read up to size, then the rest of the line is appended. Under + // the old readline-based loop the partial line was parsed (and lost + // on JSON.parse failure) AND the cursor advanced past it, so the + // second read couldn't find it. With the AUR-227 fix the cursor + // stays at the start of the unterminated tail until a full line is + // available. + const dir = mkdtempSync(join(tmpdir(), "aw-claude-")); + const session = join(dir, "session.jsonl"); + const firstHalf = + '{"type":"user","timestamp":"2026-04-25T10:00:00Z","message":{"role":"user","content":"hello world from a chunk'; + writeFileSync(session, firstHalf); + + // First read at the partial flush: nothing terminated → 0 consumed. + const sz1 = statSync(session).size; + const first = readNewlineTerminatedLines(session, 0, sz1 - 1); + expect(first.lines).toHaveLength(0); + expect(first.consumed).toBe(0); + + // Producer flushes the rest. + appendFileSync(session, ' that should arrive intact"}}\n'); + + const sz2 = statSync(session).size; + const second = readNewlineTerminatedLines( + session, + first.consumed, + sz2 - 1, + ); + expect(second.lines).toHaveLength(1); + const parsed = JSON.parse(second.lines[0]!); + expect(parsed.message.content).toBe( + "hello world from a chunk that should arrive intact", + ); + expect(first.consumed + second.consumed).toBe(sz2); + }); + + it("does not advance the cursor past an unterminated tail", () => { + const dir = mkdtempSync(join(tmpdir(), "aw-claude-")); + mkdirSync(dir, { recursive: true }); + const session = join(dir, "s.jsonl"); + // One terminated line + one partial line. + writeFileSync(session, '{"a":1}\n{"b":2,"c":'); + + const sz = statSync(session).size; + const { lines, consumed } = readNewlineTerminatedLines( + session, + 0, + sz - 1, + ); + expect(lines).toEqual(['{"a":1}']); + // Cursor sits at the start of the partial line (after the first \n), + // so when the rest arrives we re-read from there. + expect(consumed).toBe(8); + }); +}); diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index af567eb..aaa8865 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -1,69 +1,157 @@ import chokidar from "chokidar"; -import { createReadStream, existsSync, statSync } from "node:fs"; -import { createInterface } from "node:readline"; -import { basename } from "node:path"; -import type { AgentEvent, EventType } from "../schema.js"; -import { riskOf } from "../schema.js"; +import { existsSync, statSync } from "node:fs"; +import { basename, sep } from "node:path"; +import type { AgentEvent, EventType, EventSink } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; import { claudeProjectsDir } from "../util/workspace.js"; import { nextId } from "../util/ids.js"; +import { detectAgentCall } from "../util/agent-call.js"; +import { registerSpawn } from "../util/spawn-tracker.js"; +import { costOf, parseUsage } from "../util/cost.js"; +import { markAgentWrite } from "../util/recent-writes.js"; +import { readNewlineTerminatedLines } from "../util/jsonl-stream.js"; +import { createParseErrorTracker } from "../util/parse-errors.js"; -type Emit = (e: AgentEvent) => void; +type Emit = EventSink | ((e: AgentEvent) => void); + +// Shared across the adapter's lifetime so pairing survives backfill +// arriving out of order (multiple sessions emit into the same maps). +// Bounded to prevent unbounded growth when tool_result never arrives +// (agent crashed mid-turn, corrupted session file, etc). +const MAX_PENDING_TOOL_USES = 5000; +const pendingToolUses = new Map<string, { eventId: string; ts: string }>(); +const orphanResults = new Map< + string, + { ts: string; content: string; isError: boolean } +>(); + +function capMap<K, V>(m: Map<K, V>, max: number): void { + while (m.size > max) { + const first = m.keys().next().value; + if (first === undefined) break; + m.delete(first); + } +} interface FileCursor { offset: number; - buffer: string; } -export function startClaudeAdapter(emit: Emit): () => void { +/** When agentwatch restarts, each active session is backfilled from this + * many bytes behind EOF. 64 KB is ~20-50 turns — too small for heavy + * users who closed the TUI for a few hours. 4 MB covers ~days of a + * typical Claude session without blowing up memory (still bounded by + * MAX_EVENTS = 500 in the buffer). */ +const BACKFILL_BYTES = 4 * 1024 * 1024; + +export function startClaudeAdapter(sink: Emit): () => void { + const normalized = normalizeSink(sink); + const { emit, enrich } = normalized; const dir = claudeProjectsDir(); if (!existsSync(dir)) { return () => {}; } + const parseErrors = createParseErrorTracker("claude-code", normalized); const cursors = new Map<string, FileCursor>(); - - const watcher = chokidar.watch(`${dir}/**/*.jsonl`, { + // chokidar v4 dropped glob support; watch the projects dir recursively + // and filter by path regex. Two shapes matter: + // …/projects/<proj>/<session>.jsonl — main session + // …/projects/<proj>/<session>/subagents/<agent>.jsonl — subagent run + const mainRe = /[\\/]projects[\\/][^\\/]+[\\/][^\\/]+\.jsonl$/; + const subRe = /[\\/]projects[\\/][^\\/]+[\\/][^\\/]+[\\/]subagents[\\/][^\\/]+\.jsonl$/; + const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: false, - awaitWriteFinish: false, + depth: 5, }); - const process = (file: string, startFromEnd: boolean) => { + const process = (file: string, isInitialAdd: boolean) => { + const isSub = subRe.test(file); + if (!isSub && !mainRe.test(file)) return; + const size = safeSize(file); let cursor = cursors.get(file); if (!cursor) { - const size = safeSize(file); - cursor = { offset: startFromEnd ? size : 0, buffer: "" }; + const start = isInitialAdd ? Math.max(0, size - BACKFILL_BYTES) : size; + cursor = { offset: start }; cursors.set(file, cursor); - if (startFromEnd) return; } - const size = safeSize(file); if (size <= cursor.offset) return; - const stream = createReadStream(file, { - start: cursor.offset, - end: size - 1, - encoding: "utf8", - }); - + const start = cursor.offset; const sessionId = basename(file, ".jsonl"); - let consumed = 0; - const rl = createInterface({ input: stream, crlfDelay: Infinity }); + const project = extractProject(file); + const subAgentId = isSub ? extractSubAgentId(file) : undefined; + + const { lines, consumed } = readNewlineTerminatedLines( + file, + start, + size - 1, + ); + cursor.offset = start + consumed; - rl.on("line", (line) => { - consumed += Buffer.byteLength(line, "utf8") + 1; - if (!line.trim()) return; + for (let i = 0; i < lines.length; i++) { + // First line after a mid-file seek is a partial line; skip once. + if (i === 0 && isInitialAdd && start > 0) continue; + const line = lines[i]!; + if (!line.trim()) continue; + let obj: unknown; try { - const obj = JSON.parse(line); - const event = translateClaudeLine(obj, sessionId); - if (event) emit(event); + obj = JSON.parse(line); } catch { - // ignore malformed lines + parseErrors.recordFailure(sessionId, line); + continue; } - }); + // First, harvest any tool_result blocks from user turns — they + // correlate back to earlier tool_use events by tool_use_id. + handleToolResults(obj, enrich); - rl.on("close", () => { - cursor!.offset += consumed; - }); + const event = translateClaudeLine(obj, sessionId, project, subAgentId); + if (!event) continue; + emit(event); + // AUR-200: when this event invokes a child agent (codex/gemini/…), + // register the spawn so we can chain-link the child session. + if (event.details?.agentCall) { + const cwd = + typeof (obj as Record<string, unknown>).cwd === "string" + ? ((obj as Record<string, unknown>).cwd as string) + : ""; + registerSpawn({ + parentEventId: event.id, + callee: event.details.agentCall.callee, + cwd, + registeredMs: new Date(event.ts).getTime(), + }); + } + // Mark attributed writes so the fs-watcher can dedupe. + if ( + event.path && + (event.type === "file_write" || event.type === "file_read") + ) { + markAgentWrite(event.path, event.ts); + } + // If this event is a tool_use whose result already arrived + // (backfill ordering quirk), attach it immediately. + const toolUseId = event.details?.toolUseId; + if (toolUseId && orphanResults.has(toolUseId)) { + const orphan = orphanResults.get(toolUseId)!; + orphanResults.delete(toolUseId); + enrich(event.id, { + toolResult: orphan.content, + toolError: orphan.isError, + durationMs: Math.max( + 0, + new Date(orphan.ts).getTime() - new Date(event.ts).getTime(), + ), + }); + } else if (toolUseId) { + pendingToolUses.set(toolUseId, { + eventId: event.id, + ts: event.ts, + }); + capMap(pendingToolUses, MAX_PENDING_TOOL_USES); + } + } }; watcher.on("add", (f) => process(f, true)); @@ -82,6 +170,119 @@ export function startClaudeAdapter(emit: Emit): () => void { }; } +/** Claude stores session files under ~/.claude/projects/<escaped-path>/<id>.jsonl + * where the escaped path replaces `/` with `-`. We return the last segment + * (e.g. `-Users-foo-IdeaProjects-auraqu` → `auraqu`). */ +function extractProject(file: string): string { + const parts = file.split(sep); + const projIdx = parts.lastIndexOf("projects"); + if (projIdx >= 0 && parts[projIdx + 1]) { + const dir = parts[projIdx + 1]!; + const segs = dir.split("-").filter(Boolean); + return segs[segs.length - 1] ?? dir; + } + return ""; +} + +function extractSubAgentId(file: string): string { + // …/subagents/agent-<id>.jsonl → <id> + const base = basename(file, ".jsonl"); + return base.replace(/^agent-/, ""); +} + +function normalizeSink(sink: Emit): EventSink { + if (typeof sink === "function") { + return { emit: sink, enrich: () => {} }; + } + return sink; +} + +/** Claude tool results live inside user turns: message.content[] with + * type:"tool_result", tool_use_id:"...". Walk them and enrich the + * matching tool_use event. */ +function handleToolResults( + obj: unknown, + enrich: EventSink["enrich"], +): void { + if (!obj || typeof obj !== "object") return; + const o = obj as Record<string, unknown>; + const role = + o.role ?? (o.message as Record<string, unknown> | undefined)?.role; + if (role !== "user") return; + const content = (o.message as Record<string, unknown> | undefined)?.content; + if (!Array.isArray(content)) return; + const ts = + (typeof o.timestamp === "string" && o.timestamp) || + new Date().toISOString(); + + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const b = block as Record<string, unknown>; + if (b.type !== "tool_result") continue; + const id = typeof b.tool_use_id === "string" ? b.tool_use_id : undefined; + if (!id) continue; + const isError = b.is_error === true; + const resultText = flattenResultContent(b.content); + const subAgentId = extractSubAgentIdFromResult(resultText); + const pending = pendingToolUses.get(id); + if (pending) { + pendingToolUses.delete(id); + enrich(pending.eventId, { + toolResult: resultText, + toolError: isError, + durationMs: Math.max( + 0, + new Date(ts).getTime() - new Date(pending.ts).getTime(), + ), + ...(subAgentId ? { subAgentId } : {}), + }); + } else { + orphanResults.set(id, { ts, content: resultText, isError }); + if (orphanResults.size > 1000) { + const first = orphanResults.keys().next().value; + if (first) orphanResults.delete(first); + } + } + } +} + +/** When Claude's Agent tool returns, the result text includes a line like + * `agentId: ab3c99fca44a218cb` or an embedded JSON `"agentId":"..."`. + * Used to map a parent Agent tool_use event to its subagent session. */ +function extractSubAgentIdFromResult(text: string): string | undefined { + const m = + text.match(/agentId[":\s]+([a-f0-9]{16,})/) || + text.match(/agent-([a-f0-9]{16,})/); + return m?.[1]; +} + +const MAX_TOOL_RESULT_BYTES = 256 * 1024; // 256 KB hard cap; Bash stdout of a +// huge `find /` or `cat huge.log` otherwise blows up our memory. + +function flattenResultContent(content: unknown): string { + if (typeof content === "string") return capBytes(content); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (typeof c === "string") { + parts.push(c); + } else if (typeof c === "object" && c !== null) { + const rec = c as Record<string, unknown>; + if (typeof rec.text === "string") parts.push(rec.text); + } + } + return capBytes(parts.join("\n")); +} + +function capBytes(s: string, max = MAX_TOOL_RESULT_BYTES): string { + if (s.length <= max) return s; + const truncated = s.length - max; + return ( + s.slice(0, max) + + `\n\n… [${truncated.toLocaleString()} bytes truncated]` + ); +} + function safeSize(file: string): number { try { return statSync(file).size; @@ -93,106 +294,237 @@ function safeSize(file: string): number { export function translateClaudeLine( obj: unknown, sessionId: string, + project: string = "", + subAgentId?: string, ): AgentEvent | null { if (!obj || typeof obj !== "object") return null; const o = obj as Record<string, unknown>; - const ts = + const ts = clampTs( (typeof o.timestamp === "string" && o.timestamp) || - new Date().toISOString(); + new Date().toISOString(), + ); + const tagParts: string[] = []; + if (project) tagParts.push(project); + if (subAgentId) tagParts.push(`sub:${subAgentId.slice(0, 8)}`); + const prefix = tagParts.length > 0 ? `[${tagParts.join(" / ")}] ` : ""; - // Claude Code jsonl entries vary. Common shapes: user/assistant messages, - // tool_use, tool_result. We extract the signal that matters. - const type = detectType(o); - if (!type) return null; - - const { path, cmd, tool, summary } = extractFields(o); - - return { - id: nextId(), - ts, - agent: "claude-code", - type, - path, - cmd, - tool, - summary, - sessionId, - riskScore: riskOf(type, path, cmd), - }; -} - -function detectType(o: Record<string, unknown>): EventType | null { const role = o.role ?? (o.message as Record<string, unknown> | undefined)?.role; const type = o.type; + const content = (o.message as Record<string, unknown> | undefined)?.content; + + // Suppress obvious noise + if (type === "tool_result" || type === "summary") return null; + if (type === "worktree-state" || type === "compact") return null; - if (type === "user" || role === "user") return "prompt"; + // Assistant tool use — the real interesting signal. Walk content[] for + // tool_use blocks and surface the tool name + command / path. if (type === "assistant" || role === "assistant") { - if (hasToolUse(o)) return "tool_call"; - return "response"; + const msg = o.message as Record<string, unknown> | undefined; + const model = typeof msg?.model === "string" ? msg.model : "default"; + const usage = parseUsage(msg?.usage) ?? undefined; + const cost = usage ? costOf(model, usage) : undefined; + + const toolUse = findToolUse(content); + if (toolUse) { + const evType = inferToolType(toolUse.name); + const summary = buildToolSummary(toolUse); + // AUR-199: detect when this Bash tool_use is invoking another + // agent's CLI (codex exec, gemini -p, claude exec, ollama run …). + // The richer agentCall metadata fuels the call-graph view (AUR-201) + // and parent-span linkage in the OTel exporter (AUR-202). + const agentCall = + evType === "shell_exec" && toolUse.cmd + ? detectAgentCall(toolUse.cmd) + : null; + return { + id: nextId(), + ts, + agent: "claude-code", + type: evType, + path: toolUse.path, + cmd: toolUse.cmd, + tool: toolUse.name, + summary: + prefix + + (agentCall ? `→ ${agentCall.callee}: ${summary}` : summary), + sessionId, + riskScore: riskOf(evType, toolUse.path, toolUse.cmd), + details: { + toolInput: toolUse.input, + toolUseId: toolUse.id, + thinking: extractThinking(content), + usage, + cost, + model, + ...(agentCall ? { agentCall } : {}), + }, + }; + } + const text = extractText(content); + const thinking = extractThinking(content); + if (!text && !thinking) return null; + return { + id: nextId(), + ts, + agent: "claude-code", + type: "response", + summary: prefix + truncate(text || thinking || ""), + sessionId, + riskScore: riskOf("response"), + details: { + fullText: text || undefined, + thinking: thinking || undefined, + usage, + cost, + model, + }, + }; } - if (type === "tool_use") return inferToolType(o); - if (type === "tool_result") return null; // suppress noise; tool_use covered it - if (type === "summary") return null; + + if (type === "user" || role === "user") { + const text = extractUserText(content); + if (!text) return null; // suppress tool_result-only user turns + if (o.isCompactSummary === true) { + return { + id: nextId(), + ts, + agent: "claude-code", + type: "compaction", + summary: prefix + "⋈ context compacted — " + truncate(text, 60), + sessionId, + riskScore: riskOf("compaction"), + details: { fullText: text }, + }; + } + return { + id: nextId(), + ts, + agent: "claude-code", + type: "prompt", + summary: prefix + truncate(text), + sessionId, + riskScore: riskOf("prompt"), + details: { fullText: text }, + }; + } + return null; } -function hasToolUse(o: Record<string, unknown>): boolean { - const msg = o.message as Record<string, unknown> | undefined; - const content = msg?.content; - if (Array.isArray(content)) { - return content.some( - (c: unknown) => - typeof c === "object" && c !== null && (c as { type?: string }).type === "tool_use", - ); +interface ToolUse { + name: string; + path?: string; + cmd?: string; + input: Record<string, unknown>; + id?: string; +} + +function findToolUse(content: unknown): ToolUse | null { + if (!Array.isArray(content)) return null; + for (const c of content) { + if (typeof c !== "object" || c === null) continue; + const rec = c as Record<string, unknown>; + if (rec.type !== "tool_use") continue; + const name = typeof rec.name === "string" ? rec.name : "unknown"; + const id = typeof rec.id === "string" ? rec.id : undefined; + const input = (rec.input ?? {}) as Record<string, unknown>; + const path = + typeof input.file_path === "string" + ? input.file_path + : typeof input.path === "string" + ? input.path + : undefined; + const cmd = typeof input.command === "string" ? input.command : undefined; + return { name, path, cmd, input, id }; } - return false; + return null; } -function inferToolType(o: Record<string, unknown>): EventType { - const name = typeof o.name === "string" ? o.name : ""; +function extractThinking(content: unknown): string { + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (typeof c !== "object" || c === null) continue; + const rec = c as Record<string, unknown>; + if (rec.type === "thinking" && typeof rec.thinking === "string") { + parts.push(rec.thinking); + } + } + return parts.join("\n").trim(); +} + +function buildToolSummary(t: ToolUse): string { + // Prefer cmd for shell, path for file ops, a one-line arg summary otherwise. + if (/^Bash/i.test(t.name) && t.cmd) return `Bash: ${truncate(t.cmd, 100)}`; + if (/^(Write|Edit|MultiEdit|Read)/i.test(t.name) && t.path) { + return `${t.name}: ${t.path}`; + } + if (/^(Grep|Glob)/i.test(t.name)) { + const pat = + typeof t.input.pattern === "string" + ? t.input.pattern + : typeof t.input.glob === "string" + ? t.input.glob + : ""; + return `${t.name}: ${truncate(pat, 100)}`; + } + if (/^Task/i.test(t.name)) { + const desc = + typeof t.input.description === "string" ? t.input.description : ""; + return `Task: ${truncate(desc, 100)}`; + } + if (/^WebFetch/i.test(t.name)) { + const url = typeof t.input.url === "string" ? t.input.url : ""; + return `WebFetch: ${url}`; + } + // Fallback: tool name + first scalar input value + const firstVal = Object.values(t.input).find( + (v): v is string => typeof v === "string", + ); + return firstVal ? `${t.name}: ${truncate(firstVal, 100)}` : t.name; +} + +function extractText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (typeof c !== "object" || c === null) continue; + const rec = c as Record<string, unknown>; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text); + } else if (rec.type === "thinking" && typeof rec.thinking === "string") { + parts.push(rec.thinking); + } + } + return parts.join(" ").trim(); +} + +function extractUserText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (typeof c !== "object" || c === null) continue; + const rec = c as Record<string, unknown>; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text); + } + // tool_result blocks: skip — these are noise from the user's POV + } + return parts.join(" ").trim(); +} + +function inferToolType(name: string): EventType { if (/^Bash/i.test(name)) return "shell_exec"; - if (/^Read/i.test(name)) return "file_read"; + if (/^(Read|Grep|Glob)/i.test(name)) return "file_read"; if (/^(Write|Edit|MultiEdit)/i.test(name)) return "file_write"; return "tool_call"; } -function extractFields(o: Record<string, unknown>): { - path?: string; - cmd?: string; - tool?: string; - summary?: string; -} { - const name = typeof o.name === "string" ? o.name : undefined; - const input = (o.input ?? {}) as Record<string, unknown>; - const path = - typeof input.file_path === "string" - ? input.file_path - : typeof input.path === "string" - ? input.path - : undefined; - const cmd = typeof input.command === "string" ? input.command : undefined; - - let summary: string | undefined; - const msg = o.message as Record<string, unknown> | undefined; - const content = msg?.content; - if (typeof content === "string") { - summary = truncate(content); - } else if (Array.isArray(content)) { - const text = content - .filter((c: unknown): c is { type: string; text: string } => - typeof c === "object" && c !== null && (c as { type?: string }).type === "text", - ) - .map((c) => c.text) - .join(" "); - if (text) summary = truncate(text); - } - if (!summary && cmd) summary = truncate(cmd); - if (!summary && path) summary = path; - - return { path, cmd, tool: name, summary }; -} - -function truncate(s: string, max = 120): string { +function truncate(s: string, max = 140): string { const clean = s.replace(/\s+/g, " ").trim(); + if (!clean) return ""; return clean.length <= max ? clean : clean.slice(0, max - 1) + "…"; } + diff --git a/src/adapters/claude-hooks-install.ts b/src/adapters/claude-hooks-install.ts new file mode 100644 index 0000000..af93610 --- /dev/null +++ b/src/adapters/claude-hooks-install.ts @@ -0,0 +1,160 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const HOOKS_MARKER = "agentwatch-managed"; +export const DEFAULT_HOOKS_PORT = 3456; + +/** Hooks Claude Code recognises that we want to capture. Anything new + * (a future Claude release adding event types) still works because + * the receiving server has a generic fallback. */ +export const MANAGED_HOOK_EVENTS = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", +] as const; + +export function settingsPath(home?: string): string { + return join(home ?? homedir(), ".claude", "settings.json"); +} + +export function buildHookCommand(port: number, eventName: string): string { + // -m 1: 1-second timeout so a dead agentwatch never slows Claude + // -s: silent (no curl progress output) + // --data-binary @-: pipe stdin verbatim (Claude provides the event JSON) + // exit 0: never block Claude — observability must not fail-close + // [agentwatch-managed]: marker so uninstall can identify our stanzas + return `# [${HOOKS_MARKER}] ${eventName}\ncurl -s -m 1 -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:${port}/api/hooks/${eventName} > /dev/null 2>&1; exit 0`; +} + +interface ClaudeSettings { + hooks?: Record< + string, + Array<{ + matcher?: string; + hooks?: Array<{ type?: string; command?: string }>; + }> + >; + [k: string]: unknown; +} + +export interface InstallResult { + settingsPath: string; + installedEvents: string[]; + alreadyManaged: boolean; +} + +export function installClaudeHooks(opts: { port?: number; home?: string } = {}): InstallResult { + const port = opts.port ?? DEFAULT_HOOKS_PORT; + const path = settingsPath(opts.home); + mkdirSync(dirname(path), { recursive: true }); + const current = readSettings(path); + const next: ClaudeSettings = { ...current, hooks: { ...(current.hooks ?? {}) } }; + + let alreadyManaged = false; + for (const event of MANAGED_HOOK_EVENTS) { + const existing = next.hooks![event] ?? []; + const ourCommand = buildHookCommand(port, event); + const filtered = existing.filter( + (g) => !(g.hooks ?? []).some((h) => (h.command ?? "").includes(`[${HOOKS_MARKER}]`)), + ); + if (filtered.length !== existing.length) alreadyManaged = true; + filtered.push({ + matcher: ".*", + hooks: [{ type: "command", command: ourCommand }], + }); + next.hooks![event] = filtered; + } + writeSettings(path, next); + return { + settingsPath: path, + installedEvents: [...MANAGED_HOOK_EVENTS], + alreadyManaged, + }; +} + +export interface UninstallResult { + settingsPath: string; + removedEvents: string[]; +} + +export function uninstallClaudeHooks(opts: { home?: string } = {}): UninstallResult { + const path = settingsPath(opts.home); + if (!existsSync(path)) { + return { settingsPath: path, removedEvents: [] }; + } + const current = readSettings(path); + if (!current.hooks) return { settingsPath: path, removedEvents: [] }; + const removed: string[] = []; + const nextHooks: NonNullable<ClaudeSettings["hooks"]> = {}; + for (const [event, groups] of Object.entries(current.hooks)) { + const filtered = groups.filter( + (g) => !(g.hooks ?? []).some((h) => (h.command ?? "").includes(`[${HOOKS_MARKER}]`)), + ); + if (filtered.length !== groups.length) removed.push(event); + if (filtered.length > 0) nextHooks[event] = filtered; + } + const next: ClaudeSettings = { ...current, hooks: nextHooks }; + if (Object.keys(nextHooks).length === 0) delete next.hooks; + writeSettings(path, next); + return { settingsPath: path, removedEvents: removed }; +} + +export type HooksInstallStatus = "installed" | "not-installed" | "partial"; + +export interface HooksStatus { + status: HooksInstallStatus; + managedEvents: string[]; + missingEvents: string[]; + settingsPath: string; +} + +export function claudeHooksStatus(opts: { home?: string } = {}): HooksStatus { + const path = settingsPath(opts.home); + if (!existsSync(path)) { + return { + status: "not-installed", + managedEvents: [], + missingEvents: [...MANAGED_HOOK_EVENTS], + settingsPath: path, + }; + } + const settings = readSettings(path); + const managed: string[] = []; + for (const event of MANAGED_HOOK_EVENTS) { + const groups = settings.hooks?.[event] ?? []; + const has = groups.some((g) => + (g.hooks ?? []).some((h) => (h.command ?? "").includes(`[${HOOKS_MARKER}]`)), + ); + if (has) managed.push(event); + } + const missing = MANAGED_HOOK_EVENTS.filter((e) => !managed.includes(e)); + const status: HooksInstallStatus = + managed.length === 0 + ? "not-installed" + : missing.length === 0 + ? "installed" + : "partial"; + return { status, managedEvents: managed, missingEvents: missing, settingsPath: path }; +} + +function readSettings(path: string): ClaudeSettings { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as ClaudeSettings; + } catch { + return {}; + } +} + +function writeSettings(path: string, value: ClaudeSettings): void { + writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf-8"); +} diff --git a/src/adapters/claude-hooks.test.ts b/src/adapters/claude-hooks.test.ts new file mode 100644 index 0000000..5be12ad --- /dev/null +++ b/src/adapters/claude-hooks.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { translateHook } from "./claude-hooks.js"; +import { + clearHookDedup, + markHookSeen, + toolSignature, + wasHookSeen, + withClaudeHookDedup, +} from "./hooks-dedup.js"; + +describe("hooks-dedup — registry", () => { + beforeEach(() => clearHookDedup()); + + it("reports markSeen + wasSeen within the 5s window", () => { + expect(wasHookSeen("a")).toBe(false); + markHookSeen("a"); + expect(wasHookSeen("a")).toBe(true); + }); + + it("expires entries after 5s", () => { + vi.useFakeTimers(); + const t0 = Date.now(); + vi.setSystemTime(t0); + markHookSeen("b"); + vi.setSystemTime(t0 + 4_000); + expect(wasHookSeen("b")).toBe(true); + vi.setSystemTime(t0 + 6_000); + expect(wasHookSeen("b")).toBe(false); + vi.useRealTimers(); + }); + + it("toolSignature returns null when either field is missing", () => { + expect(toolSignature("s", undefined)).toBeNull(); + expect(toolSignature(undefined, "t")).toBeNull(); + expect(toolSignature("s", "t")).toBe("s:t"); + }); +}); + +describe("hooks-dedup — withClaudeHookDedup wrapper", () => { + beforeEach(() => clearHookDedup()); + + it("forwards hook events even when their signature is already marked", () => { + const captured: unknown[] = []; + const wrapped = withClaudeHookDedup({ + emit: (e) => captured.push(e.id), + enrich: () => undefined, + }); + markHookSeen("s1:t1"); + wrapped.emit({ + id: "h", + ts: "2026-05-01T10:00:00Z", + agent: "claude-code", + type: "tool_call", + riskScore: 1, + sessionId: "s1", + details: { source: "hooks", toolUseId: "t1" }, + }); + expect(captured).toEqual(["h"]); + }); + + it("drops a JSONL event when its tool signature was just marked by hooks", () => { + const captured: unknown[] = []; + const wrapped = withClaudeHookDedup({ + emit: (e) => captured.push(e.id), + enrich: () => undefined, + }); + markHookSeen("s1:t1"); + wrapped.emit({ + id: "j", + ts: "2026-05-01T10:00:00Z", + agent: "claude-code", + type: "tool_call", + riskScore: 1, + sessionId: "s1", + details: { toolUseId: "t1" }, // no source: "hooks" + }); + expect(captured).toEqual([]); // suppressed + }); + + it("doesn't dedup non-claude-code agents", () => { + const captured: unknown[] = []; + const wrapped = withClaudeHookDedup({ + emit: (e) => captured.push(e.id), + enrich: () => undefined, + }); + markHookSeen("s1:t1"); + wrapped.emit({ + id: "c", + ts: "2026-05-01T10:00:00Z", + agent: "codex", + type: "tool_call", + riskScore: 1, + sessionId: "s1", + details: { toolUseId: "t1" }, + }); + expect(captured).toEqual(["c"]); // forwarded + }); +}); + +describe("claude-hooks — translateHook", () => { + it("translates SessionStart into a session_start event", () => { + const ev = translateHook("SessionStart", { + session_id: "abc", + cwd: "/Users/x/IdeaProjects/auraqu", + source: "startup", + }); + expect(ev?.type).toBe("session_start"); + expect(ev?.sessionId).toBe("abc"); + expect(ev?.summary).toContain("[auraqu]"); + expect(ev?.summary).toContain("startup"); + expect(ev?.details?.source).toBe("hooks"); + }); + + it("translates UserPromptSubmit into a prompt event with fullText", () => { + const ev = translateHook("UserPromptSubmit", { + session_id: "s", + cwd: "/p", + prompt: "fix the auth bug", + }); + expect(ev?.type).toBe("prompt"); + expect(ev?.details?.fullText).toBe("fix the auth bug"); + }); + + it("translates PreToolUse Bash into a shell_exec with cmd populated", () => { + const ev = translateHook("PreToolUse", { + session_id: "s", + tool_name: "Bash", + tool_input: { command: "ls -la" }, + tool_use_id: "tu_1", + }); + expect(ev?.type).toBe("shell_exec"); + expect(ev?.cmd).toBe("ls -la"); + expect(ev?.details?.toolUseId).toBe("tu_1"); + }); + + it("translates PreToolUse Write into a file_write with path populated", () => { + const ev = translateHook("PreToolUse", { + session_id: "s", + tool_name: "Write", + tool_input: { file_path: "/repo/src/api.ts" }, + }); + expect(ev?.type).toBe("file_write"); + expect(ev?.path).toBe("/repo/src/api.ts"); + }); + + it("translates PostToolUse with tool_response string into toolResult", () => { + const ev = translateHook("PostToolUse", { + session_id: "s", + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_response: "a.ts\nb.ts", + }); + expect(ev?.type).toBe("tool_call"); + expect(ev?.details?.toolResult).toBe("a.ts\nb.ts"); + }); + + it("translates PreCompact / PostCompact into compaction events", () => { + const pre = translateHook("PreCompact", { + session_id: "s", + trigger: "auto", + }); + expect(pre?.type).toBe("compaction"); + expect(pre?.summary).toContain("auto"); + }); + + it("translates Stop into a session_end event", () => { + const ev = translateHook("Stop", { session_id: "s" }); + expect(ev?.type).toBe("session_end"); + }); + + it("translates Notification into a response with message text", () => { + const ev = translateHook("Notification", { + session_id: "s", + message: "Permission required for /etc", + }); + expect(ev?.type).toBe("response"); + expect(ev?.details?.fullText).toBe("Permission required for /etc"); + }); + + it("falls through unknown hook event names into a generic tool_call", () => { + const ev = translateHook("FutureNewHook", { session_id: "s" }); + expect(ev?.type).toBe("tool_call"); + expect(ev?.tool).toBe("FutureNewHook"); + expect(ev?.summary).toContain("FutureNewHook"); + }); +}); + +let homeDir: string; + +describe("claude-hooks-install — settings.json round-trip", () => { + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "agentwatch-hooks-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("installs hook stanzas for every managed event type", async () => { + const mod = await import("./claude-hooks-install.js"); + const result = mod.installClaudeHooks({ port: 3456, home: homeDir }); + const settings = JSON.parse(readFileSync(result.settingsPath, "utf-8")) as { + hooks: Record<string, unknown>; + }; + expect(Object.keys(settings.hooks).sort()).toEqual( + [...mod.MANAGED_HOOK_EVENTS].sort(), + ); + }); + + it("merges with existing user hooks instead of clobbering them", async () => { + const mod = await import("./claude-hooks-install.js"); + const settingsFile = join(homeDir, ".claude", "settings.json"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(join(homeDir, ".claude"), { recursive: true }); + writeFileSync( + settingsFile, + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: ".*", hooks: [{ type: "command", command: "user-script.sh" }] }, + ], + }, + }), + ); + mod.installClaudeHooks({ port: 3456, home: homeDir }); + const settings = JSON.parse(readFileSync(settingsFile, "utf-8")) as { + hooks: Record<string, Array<{ hooks?: Array<{ command?: string }> }>>; + }; + const preGroups = settings.hooks.PreToolUse ?? []; + const cmds = preGroups + .flatMap((g) => g.hooks ?? []) + .map((h) => h.command ?? ""); + expect(cmds.some((c) => c.includes("user-script.sh"))).toBe(true); + expect(cmds.some((c) => c.includes("agentwatch-managed"))).toBe(true); + }); + + it("uninstall removes our stanzas and leaves user stanzas intact", async () => { + const mod = await import("./claude-hooks-install.js"); + const settingsFile = join(homeDir, ".claude", "settings.json"); + mod.installClaudeHooks({ port: 3456, home: homeDir }); + const settings = JSON.parse(readFileSync(settingsFile, "utf-8")) as { + hooks: Record<string, Array<{ hooks?: Array<{ command?: string }> }>>; + }; + settings.hooks.PreToolUse?.push({ hooks: [{ command: "user-cmd" }] } as never); + writeFileSync(settingsFile, JSON.stringify(settings)); + const result = mod.uninstallClaudeHooks({ home: homeDir }); + expect(result.removedEvents.length).toBeGreaterThan(0); + const after = JSON.parse(readFileSync(settingsFile, "utf-8")) as { + hooks?: Record<string, Array<{ hooks?: Array<{ command?: string }> }>>; + }; + const userPreserved = (after.hooks?.PreToolUse ?? []) + .flatMap((g) => g.hooks ?? []) + .some((h) => (h.command ?? "").includes("user-cmd")); + expect(userPreserved).toBe(true); + }); + + it("reports status accurately before and after install", async () => { + const mod = await import("./claude-hooks-install.js"); + expect(mod.claudeHooksStatus({ home: homeDir }).status).toBe("not-installed"); + mod.installClaudeHooks({ port: 3456, home: homeDir }); + expect(mod.claudeHooksStatus({ home: homeDir }).status).toBe("installed"); + mod.uninstallClaudeHooks({ home: homeDir }); + expect(mod.claudeHooksStatus({ home: homeDir }).status).toBe("not-installed"); + }); +}); diff --git a/src/adapters/claude-hooks.ts b/src/adapters/claude-hooks.ts new file mode 100644 index 0000000..d9a6f9e --- /dev/null +++ b/src/adapters/claude-hooks.ts @@ -0,0 +1,245 @@ +import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; +import type { AgentEvent, EventDetails, EventSink, EventType } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; +import { markHookSeen, toolSignature } from "./hooks-dedup.js"; + +const KNOWN_HOOK_EVENTS = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", +] as const; + +type HookEventName = (typeof KNOWN_HOOK_EVENTS)[number] | string; + +interface HookPayload { + hook_event_name?: HookEventName; + session_id?: string; + transcript_path?: string; + cwd?: string; + tool_name?: string; + tool_input?: Record<string, unknown>; + tool_response?: Record<string, unknown> | string; + tool_use_id?: string; + prompt?: string; + message?: string; + source?: string; + trigger?: string; +} + +/** Register POST /api/hooks/:event on the Fastify app so Claude Code + * can shell-out a curl call from each hook stanza. The route is + * intentionally lenient — unknown events are still ingested as + * generic tool_call events so a future Claude release that adds new + * hook types doesn't silently drop data. */ +export function registerClaudeHooksRoute( + app: FastifyInstance, + sink: EventSink, +): void { + app.post<{ Params: { event: string }; Body: HookPayload }>( + "/api/hooks/:event", + async (req) => { + const eventName = decodeURIComponent(req.params.event); + const body = (req.body ?? {}) as HookPayload; + const event = translateHook(eventName, body); + if (!event) return { ok: false, reason: "unrecognized payload" }; + const sig = toolSignature(event.sessionId, body.tool_use_id); + if (sig) markHookSeen(sig); + sink.emit(event); + return { ok: true, eventId: event.id }; + }, + ); +} + +export function translateHook( + hookName: HookEventName, + body: HookPayload, +): AgentEvent | null { + const ts = clampTs(new Date().toISOString()); + const sessionId = body.session_id; + const cwd = body.cwd; + const id = `hooks:${randomUUID()}`; + const details: EventDetails = { + source: body.transcript_path ?? "hooks", + }; + // Stamp the canonical "via hooks" marker so the dedup wrapper can + // distinguish between a hook event (mark) and a JSONL event (check). + (details as { source?: string }).source = "hooks"; + + const projectPrefix = cwd ? `[${basenameOf(cwd)}] ` : ""; + + switch (hookName) { + case "SessionStart": { + return { + id, + ts, + agent: "claude-code", + type: "session_start", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + summary: `${projectPrefix}SessionStart${body.source ? ` (${body.source})` : ""}`, + details, + }; + } + case "SessionEnd": + case "Stop": + case "SubagentStop": { + return { + id, + ts, + agent: "claude-code", + type: "session_end", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + summary: `${projectPrefix}${hookName}`, + details, + }; + } + case "UserPromptSubmit": { + const text = body.prompt ?? ""; + return { + id, + ts, + agent: "claude-code", + type: "prompt", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + summary: `${projectPrefix}${truncate(text, 80)}`, + details: { ...details, fullText: text }, + }; + } + case "PreToolUse": { + const tool = body.tool_name ?? "tool"; + const type = mapToolToType(tool, body.tool_input); + const path = pathFromInput(body.tool_input); + const cmd = cmdFromInput(body.tool_input); + const summary = `${projectPrefix}${tool}: ${path ?? cmd ?? truncate(JSON.stringify(body.tool_input ?? {}), 60)}`; + return { + id, + ts, + agent: "claude-code", + type, + riskScore: riskOf(type, path, cmd), + ...(sessionId ? { sessionId } : {}), + tool, + summary, + ...(path ? { path } : {}), + ...(cmd ? { cmd } : {}), + details: { + ...details, + ...(body.tool_input ? { toolInput: body.tool_input } : {}), + ...(body.tool_use_id ? { toolUseId: body.tool_use_id } : {}), + }, + }; + } + case "PostToolUse": { + const tool = body.tool_name ?? "tool"; + const path = pathFromInput(body.tool_input); + const cmd = cmdFromInput(body.tool_input); + const result = + typeof body.tool_response === "string" + ? body.tool_response + : body.tool_response + ? JSON.stringify(body.tool_response) + : ""; + const summary = `${projectPrefix}${tool} done: ${path ?? cmd ?? "result"}`; + return { + id, + ts, + agent: "claude-code", + type: "tool_call", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + tool, + summary, + ...(path ? { path } : {}), + ...(cmd ? { cmd } : {}), + details: { + ...details, + toolResult: result.slice(0, 8 * 1024), + ...(body.tool_use_id ? { toolUseId: body.tool_use_id } : {}), + }, + }; + } + case "PreCompact": + case "PostCompact": { + return { + id, + ts, + agent: "claude-code", + type: "compaction", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + summary: `${projectPrefix}${hookName}${body.trigger ? ` (${body.trigger})` : ""}`, + details, + }; + } + case "Notification": { + const text = body.message ?? ""; + return { + id, + ts, + agent: "claude-code", + type: "response", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + summary: `${projectPrefix}Notification: ${truncate(text, 80)}`, + details: { ...details, fullText: text }, + }; + } + default: { + // Unknown hook — surface as a generic tool_call so the user can + // see it in the timeline, but with the original event name. + return { + id, + ts, + agent: "claude-code", + type: "tool_call", + riskScore: 1, + ...(sessionId ? { sessionId } : {}), + tool: hookName, + summary: `${projectPrefix}hook:${hookName}`, + details: { ...details, toolInput: body as unknown as Record<string, unknown> }, + }; + } + } +} + +function mapToolToType(tool: string, input?: Record<string, unknown>): EventType { + const t = tool.toLowerCase(); + if (t === "bash") return "shell_exec"; + if (t === "read") return "file_read"; + if (t === "write" || t === "edit" || t === "multiedit") return "file_write"; + if (input && (input.command || input.cmd)) return "shell_exec"; + if (input && (input.file_path || input.path)) return "file_read"; + return "tool_call"; +} + +function pathFromInput(input?: Record<string, unknown>): string | undefined { + if (!input) return undefined; + const candidate = input.file_path ?? input.path ?? input.notebook_path; + return typeof candidate === "string" ? candidate : undefined; +} + +function cmdFromInput(input?: Record<string, unknown>): string | undefined { + if (!input) return undefined; + const candidate = input.command ?? input.cmd; + return typeof candidate === "string" ? candidate : undefined; +} + +function basenameOf(p: string): string { + const idx = p.replace(/\/$/, "").lastIndexOf("/"); + return idx === -1 ? p : p.slice(idx + 1); +} + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return `${s.slice(0, n - 1)}…`; +} diff --git a/src/adapters/codex.test.ts b/src/adapters/codex.test.ts new file mode 100644 index 0000000..b6296d2 --- /dev/null +++ b/src/adapters/codex.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + codexSessionsDir, + translateCodexLine, + extractTokenUsage, +} from "./codex.js"; + +describe("extractTokenUsage", () => { + it("pulls usage out of a token_count event and maps reasoning into output", () => { + const usage = extractTokenUsage({ + type: "event_msg", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 50, + output_tokens: 20, + reasoning_output_tokens: 5, + }, + }, + }, + }); + expect(usage).toEqual({ + input: 100, + cacheRead: 50, + cacheCreate: 0, + output: 25, + }); + }); + + it("returns null for rate-limit-only token_count events (info === null)", () => { + expect( + extractTokenUsage({ + type: "event_msg", + payload: { type: "token_count", info: null }, + }), + ).toBeNull(); + }); + + it("returns null for non-token_count events", () => { + expect( + extractTokenUsage({ + type: "event_msg", + payload: { type: "agent_message" }, + }), + ).toBeNull(); + }); +}); + +describe("isCompactionEvent", () => { + it("flags turn_truncated payloads", async () => { + const { isCompactionEvent } = await import("./codex.js"); + expect( + isCompactionEvent({ type: "event_msg", payload: { type: "turn_truncated" } }), + ).toBe(true); + }); + it("ignores unrelated event_msg payloads", async () => { + const { isCompactionEvent } = await import("./codex.js"); + expect( + isCompactionEvent({ type: "event_msg", payload: { type: "token_count" } }), + ).toBe(false); + }); +}); + +describe("codexSessionsDir", () => { + it("resolves to ~/.codex/sessions", () => { + expect(codexSessionsDir("/home/u")).toBe("/home/u/.codex/sessions"); + }); +}); + +describe("translateCodexLine", () => { + it("maps user message → prompt with text + project prefix", () => { + const e = translateCodexLine( + { + timestamp: "2026-04-15T10:00:01Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hello codex" }], + }, + }, + "sess-1", + "myproj", + ); + expect(e?.type).toBe("prompt"); + expect(e?.agent).toBe("codex"); + expect(e?.details?.fullText).toBe("hello codex"); + expect(e?.summary).toContain("[myproj]"); + }); + + it("maps assistant message → response", () => { + const e = translateCodexLine( + { + timestamp: "2026-04-15T10:00:02Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hi there" }], + }, + }, + "s", + "p", + ); + expect(e?.type).toBe("response"); + expect(e?.details?.fullText).toBe("hi there"); + }); + + it("maps exec_command function_call → shell_exec with cmd", () => { + const e = translateCodexLine( + { + timestamp: "2026-04-15T10:00:03Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "ls -la", workdir: "/tmp" }), + }, + }, + "s", + "p", + ); + expect(e?.type).toBe("shell_exec"); + expect(e?.cmd).toBe("ls -la"); + expect(e?.tool).toBe("exec_command"); + }); + + it("skips developer messages and unknown types", () => { + expect( + translateCodexLine( + { + timestamp: "2026-04-15T10:00:00Z", + type: "response_item", + payload: { type: "message", role: "developer", content: [] }, + }, + "s", + "p", + ), + ).toBeNull(); + expect( + translateCodexLine( + { timestamp: "2026-04-15T10:00:00Z", type: "event_msg", payload: {} }, + "s", + "p", + ), + ).toBeNull(); + }); +}); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts new file mode 100644 index 0000000..fda006c --- /dev/null +++ b/src/adapters/codex.ts @@ -0,0 +1,395 @@ +import chokidar from "chokidar"; +import { existsSync, statSync } from "node:fs"; +import { basename, join, sep } from "node:path"; +import os from "node:os"; +import type { AgentEvent, EventSink, EventType } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; +import { nextId } from "../util/ids.js"; +import { costOf } from "../util/cost.js"; +import { consumeSpawn } from "../util/spawn-tracker.js"; +import { readNewlineTerminatedLines } from "../util/jsonl-stream.js"; +import { createParseErrorTracker } from "../util/parse-errors.js"; + +const BACKFILL_BYTES = 4 * 1024 * 1024; + +export function codexSessionsDir(home: string = os.homedir()): string { + return join(home, ".codex", "sessions"); +} + +interface Cursor { + offset: number; + project: string; + /** cwd captured from session_meta — used for AUR-200 spawn linking. */ + cwd?: string; + /** Model captured from session_meta / turn_context lines. */ + model?: string; + /** Event id of the most recently emitted assistant response. */ + lastResponseId?: string; + /** Usage last attributed to this cursor — dedup key. */ + lastUsageKey?: string; + /** Pending tool_use events waiting for their function_call_output, + * keyed by call_id. Bounded to prevent leaks on malformed sessions. */ + pendingCalls: Map<string, { eventId: string; startMs: number }>; + /** Parent agent_call event id, if this Codex session was spawned by + * another agent (Claude's `Bash(codex exec ...)`). Set on session_meta + * via consumeSpawn(); attached to the next emitted event as + * details.parentSpawnId, then cleared. */ + pendingParentSpawnId?: string; +} + +const MAX_PENDING = 2000; + +export function startCodexAdapter(sink: EventSink): () => void { + const dir = codexSessionsDir(); + if (!existsSync(dir)) return () => {}; + + const parseErrors = createParseErrorTracker("codex", sink); + const cursors = new Map<string, Cursor>(); + const rolloutRe = /rollout-[^/\\]+\.jsonl$/; + const watcher = chokidar.watch(dir, { + persistent: true, + ignoreInitial: false, + depth: 5, + }); + + const handle = (file: string, isInitialAdd: boolean) => { + if (!rolloutRe.test(file)) return; + const size = safeSize(file); + let cursor = cursors.get(file); + if (!cursor) { + const start = isInitialAdd ? Math.max(0, size - BACKFILL_BYTES) : size; + cursor = { + offset: start, + project: "", + pendingCalls: new Map(), + }; + cursors.set(file, cursor); + } + if (size <= cursor.offset) return; + + const start = cursor.offset; + const sessionId = extractSessionId(file); + const { lines, consumed } = readNewlineTerminatedLines( + file, + start, + size - 1, + ); + cursor.offset = start + consumed; + + for (let i = 0; i < lines.length; i++) { + if (i === 0 && isInitialAdd && start > 0) continue; + const line = lines[i]!; + if (!line.trim()) continue; + let obj: Record<string, unknown>; + try { + obj = JSON.parse(line) as Record<string, unknown>; + } catch { + parseErrors.recordFailure(sessionId, line); + continue; + } + const payload = (obj.payload ?? {}) as Record<string, unknown>; + if (obj.type === "session_meta") { + const cwd = payload.cwd; + if (typeof cwd === "string") { + cursor.project = projectOf(cwd); + cursor.cwd = cwd; + // AUR-200: was this Codex session spawned by a `codex exec` + // call from another agent? If so, the next event we emit + // should carry the parent linkage. + const ts = typeof obj.timestamp === "string" ? obj.timestamp : ""; + const parent = consumeSpawn( + "codex", + cwd, + ts ? new Date(ts).getTime() : Date.now(), + ); + if (parent) cursor.pendingParentSpawnId = parent.parentEventId; + } + const model = payload.model; + if (typeof model === "string") cursor.model = model; + continue; + } + if (obj.type === "turn_context") { + const model = payload.model; + if (typeof model === "string") cursor.model = model; + continue; + } + // Pair event_msg/token_count with the previous response event. + if (obj.type === "event_msg") { + const usage = extractTokenUsage(obj); + if (usage && cursor.lastResponseId) { + const key = `${usage.input}|${usage.cacheRead}|${usage.output}`; + if (cursor.lastUsageKey !== key) { + cursor.lastUsageKey = key; + const model = cursor.model ?? "gpt-5"; + const cost = costOf(model, usage); + sink.enrich(cursor.lastResponseId, { usage, cost, model }); + } + } + // Codex signals compaction via task_started/turn_truncated — + // the equivalent of Claude's isCompactSummary. + if (isCompactionEvent(obj)) { + sink.emit({ + id: nextId(), + ts: clampTs( + typeof obj.timestamp === "string" + ? obj.timestamp + : new Date().toISOString(), + ), + agent: "codex", + type: "compaction", + sessionId, + riskScore: riskOf("compaction"), + summary: `[${cursor.project}] ⋈ context compacted`, + }); + } + continue; + } + // Handle function_call_output — pair with a pending tool event. + if ( + obj.type === "response_item" && + payload.type === "function_call_output" + ) { + const callId = + typeof payload.call_id === "string" ? payload.call_id : ""; + const pend = callId ? cursor.pendingCalls.get(callId) : undefined; + if (pend) { + cursor.pendingCalls.delete(callId); + const out = payload.output; + const outText = + typeof out === "string" + ? out + : out && typeof out === "object" + ? String( + (out as Record<string, unknown>).content ?? + JSON.stringify(out), + ) + : ""; + const isError = + !!out && + typeof out === "object" && + (out as Record<string, unknown>).status === "error"; + const ts = typeof obj.timestamp === "string" ? obj.timestamp : ""; + const duration = ts + ? Math.max(0, new Date(ts).getTime() - pend.startMs) + : undefined; + sink.enrich(pend.eventId, { + toolResult: outText.slice(0, 50_000), + toolError: isError, + ...(duration != null ? { durationMs: duration } : {}), + }); + } + continue; + } + + const event = translate(obj, sessionId, cursor.project); + if (!event) continue; + // AUR-200: stamp the first event of a spawned session with its + // parent agent_call event id, then clear the pending pointer. + if (cursor.pendingParentSpawnId) { + event.details = { + ...(event.details ?? {}), + parentSpawnId: cursor.pendingParentSpawnId, + }; + cursor.pendingParentSpawnId = undefined; + } + sink.emit(event); + if (event.type === "response") cursor.lastResponseId = event.id; + // Track function_call events by call_id for later pairing. + const cid = event.details?.toolUseId; + if (cid && event.type !== "response" && event.type !== "prompt") { + cursor.pendingCalls.set(cid, { + eventId: event.id, + startMs: new Date(event.ts).getTime(), + }); + if (cursor.pendingCalls.size > MAX_PENDING) { + const firstKey = cursor.pendingCalls.keys().next().value; + if (firstKey !== undefined) cursor.pendingCalls.delete(firstKey); + } + } + } + }; + + watcher.on("add", (f) => handle(f, true)); + watcher.on("change", (f) => handle(f, false)); + watcher.on("error", (err) => { + if (typeof err === "object" && err !== null) { + const code = (err as { code?: string }).code; + if (code === "EMFILE" || code === "ENOSPC" || code === "EACCES") return; + } + // eslint-disable-next-line no-console + console.error("[agentwatch/codex]", String(err)); + }); + + return () => { + void watcher.close(); + }; +} + +/** @internal exported for tests. */ +export function translateCodexLine( + obj: Record<string, unknown>, + sessionId: string, + project: string, +): AgentEvent | null { + return translate(obj, sessionId, project); +} + +function translate( + obj: Record<string, unknown>, + sessionId: string, + project: string, +): AgentEvent | null { + const ts = clampTs(typeof obj.ts === "string" ? obj.ts : String(obj.timestamp ?? "")); + if (!ts) return null; + const payload = (obj.payload ?? {}) as Record<string, unknown>; + if (obj.type === "response_item") { + const pType = payload.type; + if (pType === "message") { + const role = payload.role as string | undefined; + if (role !== "user" && role !== "assistant") return null; + const text = extractMessageText(payload); + if (!text) return null; + const type: EventType = role === "user" ? "prompt" : "response"; + return { + id: nextId(), + ts, + agent: "codex", + type, + sessionId, + riskScore: 0, + summary: `[${project}] ${type}: ${truncate(text, 80)}`, + details: { fullText: text }, + }; + } + if (pType === "function_call") { + const name = (payload.name as string | undefined) ?? ""; + const callId = + typeof payload.call_id === "string" ? payload.call_id : ""; + const argsRaw = payload.arguments; + const args = safeJson(typeof argsRaw === "string" ? argsRaw : ""); + if (name === "exec_command" || name === "shell" || name === "write_stdin") { + const cmd = + typeof args?.cmd === "string" + ? args.cmd + : typeof args?.input === "string" + ? (args.input as string).slice(0, 200) + : ""; + return { + id: nextId(), + ts, + agent: "codex", + type: "shell_exec", + sessionId, + cmd, + tool: name, + riskScore: riskOf("shell_exec", undefined, cmd), + summary: `[${project}] shell: ${truncate(cmd, 80)}`, + details: { + toolInput: (args as Record<string, unknown> | null) ?? undefined, + toolUseId: callId || undefined, + }, + }; + } + return { + id: nextId(), + ts, + agent: "codex", + type: "tool_call", + sessionId, + tool: name, + riskScore: riskOf("tool_call", undefined, ""), + summary: `[${project}] tool: ${name}`, + details: { + toolInput: (args as Record<string, unknown> | null) ?? undefined, + toolUseId: callId || undefined, + }, + }; + } + } + return null; +} + +function extractMessageText(payload: Record<string, unknown>): string { + const content = payload.content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (c && typeof c === "object") { + const obj = c as Record<string, unknown>; + const t = obj.text; + if (typeof t === "string") parts.push(t); + } + } + return parts.join("\n").trim(); +} + +/** Codex emits a few compaction-adjacent markers. We treat any of these + * as compaction for timeline purposes: truncation_policy triggered, + * explicit `turn_truncated` event, or a task_started whose truncation + * delta is non-zero (not always present). */ +export function isCompactionEvent(obj: Record<string, unknown>): boolean { + const payload = (obj.payload ?? {}) as Record<string, unknown>; + const t = payload.type; + if (t === "turn_truncated") return true; + if (t === "compaction") return true; // future-proof for renamed event + return false; +} + +/** Pull the last-turn usage counts out of a Codex event_msg/token_count + * event. Codex schema (2026-04-15): payload.info.last_token_usage has + * input_tokens / cached_input_tokens / output_tokens / reasoning_output_tokens. + * Returns null for the periodic rate-limit-only events where info is null. */ +export function extractTokenUsage(obj: Record<string, unknown>): { + input: number; + cacheRead: number; + cacheCreate: number; + output: number; +} | null { + const payload = (obj.payload ?? {}) as Record<string, unknown>; + if (payload.type !== "token_count") return null; + const info = payload.info; + if (!info || typeof info !== "object") return null; + const last = (info as Record<string, unknown>).last_token_usage as + | Record<string, unknown> + | undefined; + if (!last) return null; + const n = (v: unknown): number => (typeof v === "number" ? v : 0); + return { + input: n(last.input_tokens), + cacheRead: n(last.cached_input_tokens), + cacheCreate: 0, + output: n(last.output_tokens) + n(last.reasoning_output_tokens), + }; +} + +function safeJson(s: string): Record<string, unknown> | null { + if (!s) return null; + try { + return JSON.parse(s) as Record<string, unknown>; + } catch { + return null; + } +} + +function projectOf(cwd: string): string { + const parts = cwd.split(sep).filter(Boolean); + return parts[parts.length - 1] ?? ""; +} + +function extractSessionId(file: string): string { + const base = basename(file, ".jsonl"); + const m = base.match(/rollout-[0-9T:\-.]+-(.+)$/); + return m?.[1] ?? base; +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} + +function safeSize(file: string): number { + try { + return statSync(file).size; + } catch { + return 0; + } +} diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 5155778..ec95f3f 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -7,25 +7,45 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { AgentEvent, EventType } from "../schema.js"; +import type { AgentEvent, EventType, EventSink } from "../schema.js"; import { riskOf } from "../schema.js"; import { nextId } from "../util/ids.js"; -type Emit = (e: AgentEvent) => void; +type Emit = EventSink | ((e: AgentEvent) => void); + +export interface CursorStatus { + installed: boolean; + mcpServers: string[]; + permissions?: { + approvalMode: string; + sandboxMode: string; + allowCount: number; + denyCount: number; + }; + cursorRulesFiles: string[]; +} /** * Cursor adapter — config-level only in v0. * - * Intentionally avoids watching the full workspace recursively (that was an - * EMFILE hazard). Strategy: - * - Watch known singletons under ~/.cursor/ (mcp.json, cli-config.json, - * ide_state.json) - * - One-shot scan of the workspace's top two directory levels for - * .cursorrules files, then watch each found file individually. + * Startup work is side-effect-free: we read the config and return a + * synchronous status snapshot for the agent panel. No events emitted on + * startup — only when files actually change. That keeps the timeline + * reserved for real activity. */ -export function startCursorAdapter(workspace: string, emit: Emit): () => void { +export function startCursorAdapter( + workspace: string, + sink: Emit, +): { stop: () => void; status: CursorStatus } { + const emit = typeof sink === "function" ? sink : sink.emit; const cursorDir = join(homedir(), ".cursor"); - if (!existsSync(cursorDir)) return () => {}; + const installed = existsSync(cursorDir); + const status: CursorStatus = { + installed, + mcpServers: [], + cursorRulesFiles: [], + }; + if (!installed) return { stop: () => {}, status }; const stoppers: Array<() => void> = []; const lastRecentFiles = new Set<string>(); @@ -47,39 +67,66 @@ export function startCursorAdapter(workspace: string, emit: Emit): () => void { }); }; - // 1) Singletons under ~/.cursor/ - const singletons = [ - join(cursorDir, "mcp.json"), - join(cursorDir, "cli-config.json"), - ]; - for (const path of singletons) { - if (!existsSync(path)) continue; - const w = chokidar.watch(path, { + // 1) MCP config — read snapshot, watch for changes only + const mcpPath = join(cursorDir, "mcp.json"); + if (existsSync(mcpPath)) { + status.mcpServers = readMcpServers(mcpPath); + const w = chokidar.watch(mcpPath, { + persistent: true, + ignoreInitial: true, + }); + w.on("change", () => { + status.mcpServers = readMcpServers(mcpPath); + emitEvent( + "file_write", + `Cursor MCP changed: ${status.mcpServers.length} server(s) (${status.mcpServers.join(", ") || "none"})`, + { path: mcpPath, tool: "cursor:mcp" }, + ); + }); + w.on("error", swallow); + stoppers.push(() => { + void w.close(); + }); + } + + // 2) Permissions (cli-config.json) — snapshot + watch for changes + const permPath = join(cursorDir, "cli-config.json"); + if (existsSync(permPath)) { + status.permissions = readPermissions(permPath); + const w = chokidar.watch(permPath, { persistent: true, - ignoreInitial: false, + ignoreInitial: true, + }); + w.on("change", () => { + status.permissions = readPermissions(permPath); + const p = status.permissions; + emitEvent( + "file_write", + `Cursor permissions changed: mode=${p?.approvalMode}, sandbox=${p?.sandboxMode}, allow=${p?.allowCount}, deny=${p?.denyCount}`, + { path: permPath, tool: "cursor:permissions" }, + ); }); - w.on("add", () => announceConfig(path, "detected", emitEvent)); - w.on("change", () => announceConfig(path, "changed", emitEvent)); w.on("error", swallow); stoppers.push(() => { void w.close(); }); } - // 2) ide_state.json — recently viewed files (rolling dedup) + // 3) ide_state.json — recently viewed files. Live signal only. const stateFile = join(cursorDir, "ide_state.json"); if (existsSync(stateFile)) { + for (const p of readRecentFiles(stateFile)) lastRecentFiles.add(p); const w = chokidar.watch(stateFile, { persistent: true, ignoreInitial: true, }); - for (const p of readRecentFiles(stateFile)) lastRecentFiles.add(p); w.on("change", () => { const recent = readRecentFiles(stateFile); for (const path of recent) { if (lastRecentFiles.has(path)) continue; lastRecentFiles.add(path); - emitEvent("file_read", path, { + const project = extractProject(path); + emitEvent("file_read", project ? `[${project}] ${path}` : path, { tool: "cursor:ide_state", path, }); @@ -91,24 +138,22 @@ export function startCursorAdapter(workspace: string, emit: Emit): () => void { }); } - // 3) .cursorrules — one-shot discovery at shallow depth, then watch - // each found file. No recursive workspace watcher — that blew the - // macOS FD limit on large workspaces. + // 4) .cursorrules — one-shot shallow discovery + per-file watch + // (no workspace-wide recursive watcher — blows fd limit on large trees) const rulesFiles = discoverCursorrules(workspace); + status.cursorRulesFiles = rulesFiles; for (const path of rulesFiles) { - emitEvent("file_read", `.cursorrules discovered: ${path}`, { - tool: "cursor:rules", - path, - }); const w = chokidar.watch(path, { persistent: true, ignoreInitial: true, }); w.on("change", () => { - emitEvent("file_write", `.cursorrules edited: ${path}`, { - tool: "cursor:rules", - path, - }); + const project = extractProject(path); + emitEvent( + "file_write", + `${project ? `[${project}] ` : ""}.cursorrules edited`, + { tool: "cursor:rules", path }, + ); }); w.on("error", swallow); stoppers.push(() => { @@ -116,8 +161,11 @@ export function startCursorAdapter(workspace: string, emit: Emit): () => void { }); } - return () => { - for (const s of stoppers) s(); + return { + stop: () => { + for (const s of stoppers) s(); + }, + status, }; } @@ -129,50 +177,34 @@ function swallow(err: unknown): void { console.error("[agentwatch/cursor]", String(err)); } -function announceConfig( - path: string, - action: "detected" | "changed", - emitEvent: (t: EventType, s: string, opts?: Partial<AgentEvent>) => void, -) { - const summary = summarizeConfig(path, action); - const type: EventType = action === "changed" ? "file_write" : "tool_call"; - emitEvent(type, summary, { path, tool: `cursor:${configName(path)}` }); -} - -function configName(path: string): string { - if (path.endsWith("mcp.json")) return "mcp"; - if (path.endsWith("cli-config.json")) return "permissions"; - return "config"; +function readMcpServers(path: string): string[] { + try { + const obj = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>; + return Object.keys((obj.mcpServers ?? {}) as Record<string, unknown>); + } catch { + return []; + } } -function summarizeConfig(path: string, action: "detected" | "changed"): string { +function readPermissions(path: string): CursorStatus["permissions"] { try { - const raw = readFileSync(path, "utf8"); - const obj = JSON.parse(raw) as Record<string, unknown>; - if (path.endsWith("mcp.json")) { - const servers = Object.keys( - (obj.mcpServers ?? {}) as Record<string, unknown>, - ); - return `Cursor MCP ${action}: ${servers.length} server${servers.length === 1 ? "" : "s"} (${servers.join(", ") || "none"})`; - } - if (path.endsWith("cli-config.json")) { - const perms = (obj.permissions ?? {}) as Record<string, unknown>; - const allow = Array.isArray(perms.allow) ? perms.allow.length : 0; - const deny = Array.isArray(perms.deny) ? perms.deny.length : 0; - const mode = obj.approvalMode ?? "unknown"; - const sandbox = (obj.sandbox as Record<string, unknown> | undefined)?.mode; - return `Cursor permissions ${action}: mode=${mode}, sandbox=${sandbox ?? "?"}, allow=${allow}, deny=${deny}`; - } - return `Cursor config ${action}: ${path}`; + const obj = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>; + const perms = (obj.permissions ?? {}) as Record<string, unknown>; + const sandbox = (obj.sandbox as Record<string, unknown> | undefined)?.mode; + return { + approvalMode: String(obj.approvalMode ?? "unknown"), + sandboxMode: String(sandbox ?? "unknown"), + allowCount: Array.isArray(perms.allow) ? perms.allow.length : 0, + denyCount: Array.isArray(perms.deny) ? perms.deny.length : 0, + }; } catch { - return `Cursor config ${action}: ${path}`; + return undefined; } } function readRecentFiles(stateFile: string): string[] { try { - const raw = readFileSync(stateFile, "utf8"); - const obj = JSON.parse(raw) as Record<string, unknown>; + const obj = JSON.parse(readFileSync(stateFile, "utf8")) as Record<string, unknown>; const list = obj.recentlyViewedFiles; if (!Array.isArray(list)) return []; return list @@ -190,7 +222,6 @@ function readRecentFiles(stateFile: string): string[] { } } -/** Shallow scan — workspace root + one level of sub-directories. */ function discoverCursorrules(workspace: string): string[] { const hits: string[] = []; if (!existsSync(workspace)) return hits; @@ -219,3 +250,10 @@ function discoverCursorrules(workspace: string): string[] { } return hits; } + +function extractProject(path: string): string { + const segs = path.split("/").filter(Boolean); + const ideaIdx = segs.indexOf("IdeaProjects"); + if (ideaIdx >= 0 && segs[ideaIdx + 1]) return segs[ideaIdx + 1]!; + return segs[segs.length - 2] ?? ""; +} diff --git a/src/adapters/detect.ts b/src/adapters/detect.ts index b62c11b..40dfe1c 100644 --- a/src/adapters/detect.ts +++ b/src/adapters/detect.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { homedir } from "node:os"; +import { homedir, platform } from "node:os"; import { join } from "node:path"; import type { AgentName } from "../schema.js"; @@ -8,40 +8,117 @@ export interface DetectedAgent { label: string; configPath?: string; present: boolean; + /** True when we detect the agent but don't yet emit events for it. + * Surface in the panel so users know it's not a bug. */ + instrumented?: boolean; +} + +function hermesStateDb(home: string): string { + const override = process.env.HERMES_HOME?.trim(); + const base = override && override.length > 0 ? override : join(home, ".hermes"); + return join(base, "state.db"); } export function detectAgents(): DetectedAgent[] { const home = homedir(); + const os = platform(); + + // Cline (VS Code extension "saoudrizwan.claude-dev") storage location + // varies by OS. + const clineDir = + os === "darwin" + ? join( + home, + "Library", + "Application Support", + "Code", + "User", + "globalStorage", + "saoudrizwan.claude-dev", + ) + : join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev"); + return [ { name: "claude-code", label: "Claude Code", configPath: join(home, ".claude", "settings.json"), present: existsSync(join(home, ".claude", "projects")), + instrumented: true, }, { - name: "codex", - label: "Codex", - configPath: join(home, ".codex", "config.toml"), - present: existsSync(join(home, ".codex")), + name: "openclaw", + label: "OpenClaw", + configPath: join(home, ".openclaw"), + present: existsSync(join(home, ".openclaw")), + instrumented: true, }, { name: "cursor", label: "Cursor", configPath: join(home, ".cursor", "mcp.json"), present: existsSync(join(home, ".cursor")), + instrumented: true, // config-level only in v0; SQLite DB TBD }, { name: "gemini", label: "Gemini CLI", configPath: join(home, ".gemini", "settings.json"), present: existsSync(join(home, ".gemini")), + instrumented: true, }, + // Detected but not yet instrumented — surfaced so users don't think + // agentwatch is broken when these show up in their workflow. { - name: "openclaw", - label: "OpenClaw", - configPath: join(home, ".openclaw"), - present: existsSync(join(home, ".openclaw")), + name: "codex", + label: "Codex", + configPath: join(home, ".codex", "sessions"), + present: existsSync(join(home, ".codex")), + instrumented: true, + }, + { + name: "hermes", + label: "Hermes Agent", + configPath: hermesStateDb(home), + present: existsSync(hermesStateDb(home)), + instrumented: true, + }, + { + name: "aider", + label: "Aider", + configPath: "./.aider.chat.history.md (per-repo)", + present: + existsSync(join(home, ".aider.chat.history.md")) || + existsSync(join(home, ".aider.input.history")), + instrumented: false, + }, + { + name: "cline", + label: "Cline (VS Code)", + configPath: clineDir, + present: existsSync(clineDir), + instrumented: false, + }, + { + name: "continue", + label: "Continue.dev", + configPath: join(home, ".continue"), + present: existsSync(join(home, ".continue")), + instrumented: false, + }, + { + name: "windsurf", + label: "Windsurf", + configPath: join(home, ".codeium"), + present: existsSync(join(home, ".codeium")), + instrumented: false, + }, + { + name: "goose", + label: "Goose (Block)", + configPath: join(home, ".config", "goose"), + present: existsSync(join(home, ".config", "goose")), + instrumented: false, }, ]; } diff --git a/src/adapters/fs-watcher.ts b/src/adapters/fs-watcher.ts index 7d6aee9..73ce74b 100644 --- a/src/adapters/fs-watcher.ts +++ b/src/adapters/fs-watcher.ts @@ -1,9 +1,10 @@ import chokidar from "chokidar"; -import type { AgentEvent } from "../schema.js"; +import type { AgentEvent, EventSink } from "../schema.js"; import { riskOf } from "../schema.js"; import { nextId } from "../util/ids.js"; +import { wasRecentlyWrittenByAgent } from "../util/recent-writes.js"; -type Emit = (e: AgentEvent) => void; +type Emit = EventSink | ((e: AgentEvent) => void); const DEFAULT_IGNORES = [ /(^|[/\\])node_modules([/\\]|$)/, @@ -28,14 +29,30 @@ const DEFAULT_IGNORES = [ /pnpm-lock\.yaml$/, /yarn\.lock$/, /bun\.lockb$/, + // Terminal recording artifacts — asciinema's .cast, ttyrec, etc. + /\.cast$/, + /\.ttyrec$/, + // Our own demo assets — don't surface recording a demo as agent activity + /(^|[/\\])docs[/\\]demo\./, ]; -export function startFsAdapter(root: string, emit: Emit): () => void { +export function startFsAdapter(root: string, sink: Emit): () => void { + const emit = typeof sink === "function" ? sink : sink.emit; + // Unless explicitly opted in, skip watching the workspace tree at all. + // The fs-watcher was a nice-to-have that doesn't pull its weight on + // large monorepos — a 10k-dir tree can take seconds to establish + // watches and exhausts inotify limits on Linux. Opt in via + // AGENTWATCH_WATCH_WORKSPACE=1 when you actually want generic file + // change events alongside agent activity. + if (process.env.AGENTWATCH_WATCH_WORKSPACE !== "1") { + return () => {}; + } const watcher = chokidar.watch(root, { persistent: true, ignoreInitial: true, ignored: (p) => DEFAULT_IGNORES.some((r) => r.test(p)), - depth: 3, + depth: 2, + awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 }, }); watcher.on("error", (err) => { @@ -45,6 +62,9 @@ export function startFsAdapter(root: string, emit: Emit): () => void { }); const emitFs = (path: string) => { + // Skip paths already attributed to an agent write within the dedupe + // window — avoids double-counting Claude's own Edit / Write / MultiEdit. + if (wasRecentlyWrittenByAgent(path)) return; const event: AgentEvent = { id: nextId(), ts: new Date().toISOString(), diff --git a/src/adapters/gemini.test.ts b/src/adapters/gemini.test.ts new file mode 100644 index 0000000..88b6aab --- /dev/null +++ b/src/adapters/gemini.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { extractGeminiUsage } from "./gemini.js"; + +describe("extractGeminiUsage", () => { + it("subtracts cached from total input to get fresh input", () => { + const u = extractGeminiUsage({ + tokens: { input: 5000, output: 120, cached: 2000, thoughts: 0, tool: 0 }, + }); + expect(u).toEqual({ + input: 3000, + cacheCreate: 0, + cacheRead: 2000, + output: 120, + }); + }); + + it("folds thoughts and tool tokens into output", () => { + const u = extractGeminiUsage({ + tokens: { + input: 1000, + output: 10, + cached: 0, + thoughts: 50, + tool: 5, + }, + }); + expect(u?.output).toBe(65); + }); + + it("returns null when no tokens object is present", () => { + expect(extractGeminiUsage({})).toBeNull(); + }); + + it("returns null when every token field is zero", () => { + expect( + extractGeminiUsage({ + tokens: { input: 0, output: 0, cached: 0, thoughts: 0, tool: 0 }, + }), + ).toBeNull(); + }); +}); diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts new file mode 100644 index 0000000..e07e0e8 --- /dev/null +++ b/src/adapters/gemini.ts @@ -0,0 +1,365 @@ +import chokidar from "chokidar"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join, sep } from "node:path"; +import type { AgentEvent, EventSink, EventType } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; +import { nextId } from "../util/ids.js"; +import { costOf, type Usage } from "../util/cost.js"; +import { consumeSpawn } from "../util/spawn-tracker.js"; + +type Emit = EventSink | ((e: AgentEvent) => void); + +/** + * Gemini CLI adapter. + * + * Storage layout (observed on dev machine): + * ~/.gemini/tmp/<projectDir>/chats/session-<ts>-<hash>.json + * + * Each file is a single JSON document (not JSONL) with the shape: + * { sessionId, projectHash, startTime, lastUpdated, kind, messages: [...] } + * + * Messages have { id, timestamp, type: "user" | "gemini" | "error" | "info", + * content: [{ text }] } + * + * Sessions can be `kind: "main"` (top-level) or `kind: "subagent"` (spawned + * via Gemini's delegation). + * + * Gemini's text doesn't contain structured tool_use blocks like Claude's. + * The assistant describes tool intent in prose; we surface that as + * `response` events. Heuristic detection of explicit run_shell_command or + * write_file language would be brittle, so we don't try. + */ +export function startGeminiAdapter(sink: Emit): () => void { + const { emit } = normalizeSink(sink); + const root = join(homedir(), ".gemini", "tmp"); + if (!existsSync(root)) return () => {}; + + // Track which message ids we've already emitted per session, keyed by + // filename (sessions share ids across files in theory but filename is a + // safer dedupe key). + const emittedIds = new Map<string, Set<string>>(); + // AUR-200: per-file pending parent agent_call event id, set on the + // first sighting of a Gemini session and consumed by the first event + // we emit from that file. + const pendingParentByFile = new Map<string, string>(); + + const watcher = chokidar.watch(root, { + persistent: true, + ignoreInitial: false, + depth: 4, + }); + + const sessionRe = /[\\/]chats[\\/]session-[^\\/]+\.json$/; + + const process = (file: string, _isInitial: boolean) => { + if (!sessionRe.test(file)) return; + let doc: unknown; + try { + doc = JSON.parse(readFileSync(file, "utf8")); + } catch { + return; + } + if (!doc || typeof doc !== "object") return; + const d = doc as Record<string, unknown>; + const sessionId = + (typeof d.sessionId === "string" && d.sessionId) || basename(file, ".json"); + const kind = typeof d.kind === "string" ? d.kind : "main"; + const project = extractProject(file); + const messages = Array.isArray(d.messages) ? d.messages : []; + + let seen = emittedIds.get(file); + if (!seen) { + seen = new Set(); + emittedIds.set(file, seen); + // AUR-200: first time we see this Gemini session, check if it + // was spawned by a `gemini -p ...` call from another agent. + // We use empty cwd because Gemini's chat JSON doesn't carry it + // (spawn-tracker treats empty as a wildcard, bounded by 60s TTL). + const startTime = + typeof d.startTime === "string" ? d.startTime : undefined; + const spawnTs = startTime ? new Date(startTime).getTime() : Date.now(); + const parent = consumeSpawn("gemini", "", spawnTs); + if (parent) pendingParentByFile.set(file, parent.parentEventId); + } + + let firstEventEmitted = false; + for (const m of messages) { + if (!m || typeof m !== "object") continue; + const msg = m as Record<string, unknown>; + const id = typeof msg.id === "string" ? msg.id : undefined; + if (!id || seen.has(id)) continue; + seen.add(id); + + const ev = translate(msg, sessionId, kind, project); + if (ev) { + const parent = pendingParentByFile.get(file); + if (parent && !firstEventEmitted) { + ev.details = { ...(ev.details ?? {}), parentSpawnId: parent }; + pendingParentByFile.delete(file); + firstEventEmitted = true; + } + emit(ev); + } + + // Each Gemini assistant message can carry an array of toolCalls, + // each already including the inline functionResponse. Emit one + // event per tool with the result attached — no pairing needed. + const toolCalls = msg.toolCalls; + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls) { + const te = translateToolCall(tc, msg, sessionId, kind, project); + if (te) emit(te); + } + } + } + }; + + watcher.on("add", (f) => process(f, true)); + watcher.on("change", (f) => process(f, false)); + watcher.on("error", swallow); + + return () => { + void watcher.close(); + }; +} + +function translate( + msg: Record<string, unknown>, + sessionId: string, + kind: string, + project: string, +): AgentEvent | null { + const ts = clampTs( + (typeof msg.timestamp === "string" && msg.timestamp) || + new Date().toISOString(), + ); + const type = typeof msg.type === "string" ? msg.type : ""; + const text = extractText(msg.content); + + const subAgentSuffix = kind === "subagent" ? " / sub:gemini" : ""; + const prefix = project ? `[${project}${subAgentSuffix}] ` : ""; + + let eventType: EventType; + if (type === "user") { + if (!text) return null; + eventType = "prompt"; + } else if (type === "gemini") { + if (!text) return null; + eventType = "response"; + } else if (type === "error") { + if (!text) return null; + eventType = "response"; + } else { + return null; // skip info messages + } + + const usage = extractGeminiUsage(msg); + const model = pickModel(msg); + const cost = usage ? costOf(model, usage) : undefined; + + return { + id: nextId(), + ts, + agent: "gemini", + type: eventType, + sessionId, + summary: prefix + truncate(text), + riskScore: type === "error" ? 6 : riskOf(eventType), + tool: kind === "subagent" ? "gemini:subagent" : "gemini", + details: { + fullText: text, + ...(usage ? { usage, cost, model } : {}), + ...(typeof (msg.thoughts as unknown) === "string" && msg.thoughts + ? { thinking: msg.thoughts as string } + : {}), + }, + }; +} + +function pickModel(msg: Record<string, unknown>): string { + if (typeof msg.model === "string") return msg.model; + if (typeof msg.modelVersion === "string") return msg.modelVersion; + return "gemini-2.5-pro"; +} + +/** Map a Gemini tool name to our event type + shape. */ +function inferGeminiToolType(name: string): { + type: EventType; + path?: "file_path" | "path" | null; +} { + const n = name.toLowerCase(); + if (n === "read_file" || n === "read_many_files") { + return { type: "file_read", path: "file_path" }; + } + if ( + n === "write_file" || + n === "replace" || + n === "edit" || + n === "create_file" + ) { + return { type: "file_write", path: "file_path" }; + } + if (n === "run_shell_command" || n === "shell") { + return { type: "shell_exec" }; + } + return { type: "tool_call" }; +} + +/** Extract the result string out of Gemini's nested toolCall.result shape: + * [{ functionResponse: { response: { output: "..." } } }] */ +function extractToolResult(raw: unknown): { + text: string; + isError: boolean; +} { + if (!Array.isArray(raw)) return { text: "", isError: false }; + const parts: string[] = []; + let isError = false; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const fr = (item as Record<string, unknown>).functionResponse; + if (!fr || typeof fr !== "object") continue; + const resp = (fr as Record<string, unknown>).response; + if (!resp || typeof resp !== "object") continue; + const r = resp as Record<string, unknown>; + const out = r.output ?? r.error ?? r.content; + if (typeof out === "string") parts.push(out); + else if (out != null) parts.push(JSON.stringify(out)); + if (r.error) isError = true; + } + return { text: parts.join("\n\n").slice(0, 50_000), isError }; +} + +function translateToolCall( + tc: unknown, + parent: Record<string, unknown>, + sessionId: string, + kind: string, + project: string, +): AgentEvent | null { + if (!tc || typeof tc !== "object") return null; + const c = tc as Record<string, unknown>; + const name = typeof c.name === "string" ? c.name : "tool"; + const id = typeof c.id === "string" ? c.id : undefined; + const args = (c.args ?? {}) as Record<string, unknown>; + const { type, path: pathKey } = inferGeminiToolType(name); + const path = + pathKey && typeof args[pathKey] === "string" + ? (args[pathKey] as string) + : undefined; + const cmd = + type === "shell_exec" && typeof args.command === "string" + ? (args.command as string) + : undefined; + const ts = clampTs( + typeof parent.timestamp === "string" + ? parent.timestamp + : new Date().toISOString(), + ); + const subAgentSuffix = kind === "subagent" ? " / sub:gemini" : ""; + const prefix = project ? `[${project}${subAgentSuffix}] ` : ""; + const { text: toolResult, isError } = extractToolResult(c.result); + return { + id: nextId(), + ts, + agent: "gemini", + type, + tool: `gemini:${name}`, + sessionId, + path, + cmd, + riskScore: riskOf(type, path, cmd), + summary: prefix + (cmd ?? path ?? name), + details: { + toolInput: args, + toolUseId: id, + ...(toolResult ? { toolResult } : {}), + ...(isError ? { toolError: true } : {}), + }, + }; +} + +/** Gemini CLI emits tokens as: + * { input, output, cached, thoughts, tool, total } + * + * - `input` is the *total* input (including cached prefix) + * - `cached` is the portion served from cache + * - `output` is the visible response tokens + * - `thoughts` is private chain-of-thought tokens, billed separately + * + * Our schema wants `input` = fresh uncached input, so we subtract. + * We fold `thoughts` into `output` because both are billed at output + * rates on current Gemini pricing tiers. */ +export function extractGeminiUsage( + msg: Record<string, unknown>, +): Usage | null { + const t = msg.tokens; + if (!t || typeof t !== "object") return null; + const n = (v: unknown): number => (typeof v === "number" ? v : 0); + const o = t as Record<string, unknown>; + const input = n(o.input); + const cached = n(o.cached); + const output = n(o.output) + n(o.thoughts) + n(o.tool); + const cacheRead = Math.max(0, cached); + const fresh = Math.max(0, input - cached); + if (fresh + cacheRead + output === 0) return null; + return { + input: fresh, + cacheCreate: 0, + cacheRead, + output, + }; +} + +function extractText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const item of content) { + if (typeof item === "string") { + parts.push(item); + } else if (item && typeof item === "object") { + const rec = item as Record<string, unknown>; + if (typeof rec.text === "string") parts.push(rec.text); + } + } + return parts.join("\n").trim(); +} + +function extractProject(file: string): string { + // Expected: ~/.gemini/tmp/<projectDir>/chats/<session>.json + // Extract the segment immediately after /tmp/ that is NOT "chats" + // (guards against sessions stored in unexpected depths). + const parts = file.split(sep); + const tmpIdx = parts.lastIndexOf("tmp"); + if (tmpIdx >= 0) { + const candidate = parts[tmpIdx + 1]; + if (candidate && candidate !== "chats") return candidate; + } + // Fallback: parent of /chats/ + const chatsIdx = parts.lastIndexOf("chats"); + if (chatsIdx > 0) { + const cand = parts[chatsIdx - 1]; + if (cand && cand !== "tmp") return cand; + } + return ""; +} + +function truncate(s: string, max = 140): string { + const clean = s.replace(/\s+/g, " ").trim(); + if (!clean) return ""; + return clean.length <= max ? clean : clean.slice(0, max - 1) + "…"; +} + +function normalizeSink(sink: Emit): EventSink { + if (typeof sink === "function") return { emit: sink, enrich: () => {} }; + return sink; +} + +function swallow(err: unknown): void { + if (typeof err !== "object" || err === null) return; + const code = (err as { code?: string }).code; + if (code === "EMFILE" || code === "ENOSPC" || code === "EACCES") return; + // eslint-disable-next-line no-console + console.error("[agentwatch/gemini]", String(err)); +} diff --git a/src/adapters/hermes.integration.test.ts b/src/adapters/hermes.integration.test.ts new file mode 100644 index 0000000..7ce85f4 --- /dev/null +++ b/src/adapters/hermes.integration.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { startHermesAdapter } from "./hermes.js"; +import type { AgentEvent } from "../schema.js"; + +// Schema mirrors hermes-agent/hermes_state.py verbatim (sessions + messages). +// If hermes changes the schema, this test fails — which is the point. +const HERMES_SCHEMA = ` +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) +); +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT +); +`; + +describe("startHermesAdapter (integration)", () => { + let tmpDir: string; + let dbPath: string; + let events: AgentEvent[]; + let stop: (() => void) | null; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "hermes-adapter-")); + dbPath = join(tmpDir, "state.db"); + const seed = new Database(dbPath); + seed.pragma("journal_mode = WAL"); + seed.exec(HERMES_SCHEMA); + seed.close(); + + process.env.HERMES_DB_PATH = dbPath; + events = []; + stop = null; + }); + + afterEach(() => { + if (stop) stop(); + delete process.env.HERMES_DB_PATH; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("no-ops silently when the db file doesn't exist", () => { + process.env.HERMES_DB_PATH = join(tmpDir, "does-not-exist.db"); + stop = startHermesAdapter((e) => events.push(e)); + expect(events).toEqual([]); + stop(); + stop = null; + }); + + it("backfills recent pre-existing rows on boot (session_start + prompt)", () => { + // Design: mirror claude-code/codex/gemini adapters — read recent + // history on startup so the UI has context immediately. + const db = new Database(dbPath); + const now = Date.now() / 1000; + db.prepare( + "INSERT INTO sessions (id, source, model, started_at, ended_at) VALUES (?, ?, ?, ?, ?)", + ).run("s1", "cli", "hermes-3", now, now + 5); + db.prepare( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + ).run("s1", "user", "hello", now); + db.close(); + + stop = startHermesAdapter((e) => events.push(e)); + + // Should emit session_start, session_end (since ended_at set), and prompt. + const types = events.map((e) => e.type).sort(); + expect(types).toContain("session_start"); + expect(types).toContain("session_end"); + expect(types).toContain("prompt"); + }); + + it("picks up a new session + message written AFTER the adapter starts", { timeout: 10_000 }, async () => { + stop = startHermesAdapter((e) => events.push(e)); + expect(events).toEqual([]); + + const db = new Database(dbPath); + const now = Date.now() / 1000; + db.prepare( + "INSERT INTO sessions (id, source, model, started_at) VALUES (?, ?, ?, ?)", + ).run("s4", "cli", "hermes-3", now); + db.prepare( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + ).run("s4", "user", "delta message", now + 1); + db.close(); + + // Wait past the 2s safety-net poll. + await new Promise((r) => setTimeout(r, 3_000)); + + const sessionStarts = events.filter((e) => e.type === "session_start" && e.sessionId === "s4"); + const prompts = events.filter((e) => e.type === "prompt" && e.details?.fullText === "delta message"); + expect(sessionStarts.length).toBe(1); + expect(prompts.length).toBe(1); + }); + + it("emits session_end when a previously-open session transitions to ended_at != null", { timeout: 10_000 }, async () => { + // Seed an OPEN session before adapter boot (so it's tracked in openSessionIds). + const seed = new Database(dbPath); + const now = Date.now() / 1000; + seed.prepare( + "INSERT INTO sessions (id, source, model, started_at) VALUES (?, ?, ?, ?)", + ).run("s-open", "cli", "hermes-3", now); + seed.close(); + + stop = startHermesAdapter((e) => events.push(e)); + // Boot may emit session_start for pre-existing session (now backfilled). + // Clear and wait for the *transition* which we're actually testing. + events.length = 0; + + // Now close the session — simulates hermes finishing it. + const db = new Database(dbPath); + db.prepare( + `UPDATE sessions + SET ended_at=?, end_reason=?, input_tokens=?, output_tokens=?, + cache_read_tokens=?, cache_write_tokens=?, actual_cost_usd=? + WHERE id=?`, + ).run(now + 10, "normal", 100, 42, 10, 5, 0.001, "s-open"); + db.close(); + + await new Promise((r) => setTimeout(r, 3_000)); + + const endEvt = events.find((e) => e.type === "session_end" && e.sessionId === "s-open"); + expect(endEvt).toBeDefined(); + expect(endEvt?.details?.usage).toEqual({ + input: 100, + cacheCreate: 5, + cacheRead: 10, + output: 42, + }); + expect(endEvt?.details?.cost).toBe(0.001); + }); + + it("skips role=tool and role=system messages (no prompt/response emitted)", { timeout: 10_000 }, async () => { + stop = startHermesAdapter((e) => events.push(e)); + + const db = new Database(dbPath); + const now = Date.now() / 1000; + db.prepare("INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)").run( + "s3", + "cli", + now, + ); + db.prepare( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + ).run("s3", "system", "you are hermes", now); + db.prepare( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + ).run("s3", "tool", '{"result": "ok"}', now + 1); + db.close(); + + await new Promise((r) => setTimeout(r, 3_000)); + + // session_start should fire (s3 is a new session), but NO prompt/response/tool_call. + expect(events.find((e) => e.type === "session_start" && e.sessionId === "s3")).toBeDefined(); + const msgEvents = events.filter( + (e) => (e.type === "prompt" || e.type === "response" || e.type === "tool_call") && e.sessionId === "s3", + ); + expect(msgEvents).toEqual([]); + }); +}); diff --git a/src/adapters/hermes.test.ts b/src/adapters/hermes.test.ts new file mode 100644 index 0000000..9c11879 --- /dev/null +++ b/src/adapters/hermes.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + translateHermesSessionStart, + translateHermesSessionEnd, + translateHermesMessage, + type HermesMessage, + type HermesSession, +} from "./hermes.js"; + +const BASE_SESSION: HermesSession = { + id: "sess-abcdef12", + source: "cli", + user_id: "misha", + model: "hermes-3-70b", + parent_session_id: null, + started_at: 1715000000, + ended_at: null, + end_reason: null, + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + cache_write_tokens: null, + actual_cost_usd: null, + estimated_cost_usd: null, +}; + +describe("translateHermesSessionStart", () => { + it("emits session_start tagged agent=hermes with model + parent linkage", () => { + const e = translateHermesSessionStart( + { ...BASE_SESSION, parent_session_id: "parent-xyz" }, + "/home/u/.hermes/state.db", + ); + expect(e.agent).toBe("hermes"); + expect(e.type).toBe("session_start"); + expect(e.sessionId).toBe("sess-abcdef12"); + expect(e.details?.model).toBe("hermes-3-70b"); + expect(e.details?.parentSpawnId).toBe("parent-xyz"); + expect(e.summary).toContain("sess-abc"); + }); +}); + +describe("translateHermesSessionEnd", () => { + it("carries usage totals and cost into details", () => { + const e = translateHermesSessionEnd( + { + ...BASE_SESSION, + ended_at: 1715000500, + end_reason: "normal", + input_tokens: 1234, + output_tokens: 567, + cache_read_tokens: 100, + cache_write_tokens: 50, + actual_cost_usd: 0.042, + }, + "/home/u/.hermes/state.db", + ); + expect(e.type).toBe("session_end"); + expect(e.details?.usage).toEqual({ + input: 1234, + cacheCreate: 50, + cacheRead: 100, + output: 567, + }); + expect(e.details?.cost).toBe(0.042); + expect(e.summary).toContain("normal"); + }); +}); + +const BASE_MSG: HermesMessage = { + id: 1, + session_id: "sess-abcdef12", + role: "user", + content: null, + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 1715000010, + token_count: null, + finish_reason: null, + reasoning: null, +}; + +describe("translateHermesMessage", () => { + it("maps role=user to prompt with content in fullText", () => { + const e = translateHermesMessage( + { ...BASE_MSG, role: "user", content: "run the eval suite" }, + "/db", + ); + expect(e?.type).toBe("prompt"); + expect(e?.agent).toBe("hermes"); + expect(e?.details?.fullText).toBe("run the eval suite"); + expect(e?.summary).toContain("run the eval"); + }); + + it("maps role=assistant with no tool_calls to response (reasoning preserved)", () => { + const e = translateHermesMessage( + { + ...BASE_MSG, + role: "assistant", + content: "here is the plan", + reasoning: "step-by-step analysis", + }, + "/db", + ); + expect(e?.type).toBe("response"); + expect(e?.details?.fullText).toBe("here is the plan"); + expect(e?.details?.thinking).toBe("step-by-step analysis"); + }); + + it("maps role=assistant with tool_calls JSON to tool_call with parsed name + input", () => { + const e = translateHermesMessage( + { + ...BASE_MSG, + role: "assistant", + content: null, + tool_calls: JSON.stringify([ + { + id: "call_1", + function: { name: "search_web", arguments: JSON.stringify({ q: "llama" }) }, + }, + ]), + }, + "/db", + ); + expect(e?.type).toBe("tool_call"); + expect(e?.tool).toBe("search_web"); + expect(e?.details?.toolInput).toEqual({ q: "llama" }); + expect(e?.summary).toContain("search_web"); + }); + + it("wraps non-JSON tool arguments as { raw } so data isn't lost", () => { + const e = translateHermesMessage( + { + ...BASE_MSG, + role: "assistant", + tool_calls: JSON.stringify([ + { function: { name: "echo", arguments: "not-json" } }, + ]), + }, + "/db", + ); + expect(e?.type).toBe("tool_call"); + expect(e?.details?.toolInput).toEqual({ raw: "not-json" }); + }); + + it("forwards tool_call_id as toolUseId for later pairing with tool_result", () => { + const e = translateHermesMessage( + { + ...BASE_MSG, + role: "assistant", + tool_calls: JSON.stringify([{ function: { name: "t" } }]), + tool_call_id: "tc-123", + }, + "/db", + ); + expect(e?.details?.toolUseId).toBe("tc-123"); + }); + + it("drops role=tool and role=system messages", () => { + expect(translateHermesMessage({ ...BASE_MSG, role: "tool" }, "/db")).toBeNull(); + expect(translateHermesMessage({ ...BASE_MSG, role: "function" }, "/db")).toBeNull(); + expect(translateHermesMessage({ ...BASE_MSG, role: "system" }, "/db")).toBeNull(); + }); + + it("truncates long content into a single-line summary under 140 chars", () => { + const long = "x".repeat(500); + const e = translateHermesMessage( + { ...BASE_MSG, role: "user", content: long }, + "/db", + ); + expect(e?.summary?.length).toBeLessThanOrEqual(140); + expect(e?.summary?.endsWith("...")).toBe(true); + }); +}); diff --git a/src/adapters/hermes.ts b/src/adapters/hermes.ts new file mode 100644 index 0000000..8ed5d2a --- /dev/null +++ b/src/adapters/hermes.ts @@ -0,0 +1,341 @@ +import chokidar from "chokidar"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import type { Database as DB } from "better-sqlite3"; +import type { AgentEvent, EventSink, EventType } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; +import { nextId } from "../util/ids.js"; + +/** + * Hermes Agent adapter (NousResearch/hermes-agent). + * + * Unlike Claude Code / Codex / OpenClaw which write JSONL session files, + * hermes persists everything to a single SQLite DB at ~/.hermes/state.db + * with FTS5 indexing. Schema (from hermes_state.py @ schema version 6): + * + * sessions (id PK, source, user_id, model, parent_session_id, + * started_at, ended_at, end_reason, message_count, + * tool_call_count, input_tokens, output_tokens, + * cache_read_tokens, cache_write_tokens, reasoning_tokens, + * actual_cost_usd, estimated_cost_usd, ...) + * messages (id PK, session_id FK, role, content, tool_call_id, + * tool_calls JSON, tool_name, timestamp REAL, token_count, + * finish_reason, reasoning, ...) + * messages_fts (virtual FTS5 on content) + * + * Strategy: watch the db file + WAL sidecar with chokidar; on any + * change, poll for new sessions (started_at > last_started) and + * new messages (id > last_message_id). SQLite WAL mode means readers + * don't block writers, so polling is safe. + * + * Emits: + * - session_start → when a new sessions row appears + * - session_end → when an existing sessions.ended_at flips non-null + * - prompt → messages.role = 'user' + * - response → messages.role = 'assistant' (usage from session totals) + * - tool_call → messages.role = 'tool' or tool_calls non-empty + * + * Subagent linkage: sessions.parent_session_id → maps to agentwatch's + * parentSpawnId convention (first event of a spawned session gets the + * parent's last known event id stamped). + * + * AUR-229. + */ + +type Emit = EventSink | ((e: AgentEvent) => void); + +export interface HermesMessage { + id: number; + session_id: string; + role: string; + content: string | null; + tool_call_id: string | null; + tool_calls: string | null; + tool_name: string | null; + timestamp: number; + token_count: number | null; + finish_reason: string | null; + reasoning: string | null; +} + +export interface HermesSession { + id: string; + source: string | null; + user_id: string | null; + model: string | null; + parent_session_id: string | null; + started_at: number; + ended_at: number | null; + end_reason: string | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + actual_cost_usd: number | null; + estimated_cost_usd: number | null; +} + +function resolveHermesDbPath(): string { + // Explicit override wins (useful for tests + non-standard installs). + const explicit = process.env.HERMES_DB_PATH?.trim(); + if (explicit && explicit.length > 0) return explicit; + // Match hermes's own convention: HERMES_HOME → $HERMES_HOME/state.db. + const hermesHome = process.env.HERMES_HOME?.trim(); + const base = hermesHome && hermesHome.length > 0 ? hermesHome : join(homedir(), ".hermes"); + return join(base, "state.db"); +} + +const DEFAULT_DB_PATH = join(homedir(), ".hermes", "state.db"); + +export function translateHermesSessionStart(s: HermesSession, source: string): AgentEvent { + const ts = new Date(Math.floor(s.started_at * 1000)).toISOString(); + return { + id: nextId(), + ts: clampTs(ts), + agent: "hermes", + type: "session_start", + sessionId: s.id, + riskScore: riskOf("session_start"), + summary: `session ${s.id.slice(0, 8)} (${s.source ?? "unknown"}${s.model ? ", " + s.model : ""})`, + details: { + source, + model: s.model ?? undefined, + parentSpawnId: s.parent_session_id ?? undefined, + }, + }; +} + +export function translateHermesSessionEnd(s: HermesSession, source: string): AgentEvent { + const endedAtSec = s.ended_at ?? s.started_at; + const ts = new Date(Math.floor(endedAtSec * 1000)).toISOString(); + return { + id: nextId(), + ts: clampTs(ts), + agent: "hermes", + type: "session_end", + sessionId: s.id, + riskScore: riskOf("session_end"), + summary: `session ${s.id.slice(0, 8)} ended${s.end_reason ? " (" + s.end_reason + ")" : ""}`, + details: { + source, + model: s.model ?? undefined, + usage: { + input: s.input_tokens ?? 0, + cacheCreate: s.cache_write_tokens ?? 0, + cacheRead: s.cache_read_tokens ?? 0, + output: s.output_tokens ?? 0, + }, + cost: s.actual_cost_usd ?? s.estimated_cost_usd ?? undefined, + }, + }; +} + +export function translateHermesMessage(m: HermesMessage, source: string): AgentEvent | null { + const ts = new Date(Math.floor(m.timestamp * 1000)).toISOString(); + + // Tool calls can appear two ways in hermes: + // - role='tool' entries (the tool's RESULT row, correlated by tool_call_id) + // - role='assistant' entries with tool_calls JSON populated (the REQUEST) + // We emit tool_call for the assistant request and drop the tool-result rows — + // agentwatch pairs tool_call → tool_result by toolUseId elsewhere. + let type: EventType; + let toolInput: Record<string, unknown> | undefined; + let toolName: string | undefined; + if (m.role === "user") { + type = "prompt"; + } else if (m.role === "assistant") { + if (m.tool_calls) { + type = "tool_call"; + try { + const parsed = JSON.parse(m.tool_calls); + if (Array.isArray(parsed) && parsed.length > 0) { + const first = parsed[0] as { function?: { name?: string; arguments?: string }; name?: string }; + toolName = first.function?.name ?? first.name ?? "tool"; + const argsStr = first.function?.arguments; + if (argsStr) { + try { + toolInput = JSON.parse(argsStr); + } catch { + toolInput = { raw: argsStr }; + } + } + } + } catch { + // tool_calls wasn't JSON — leave toolInput undefined + } + } else { + type = "response"; + } + } else if (m.role === "tool" || m.role === "function") { + return null; + } else if (m.role === "system") { + return null; + } else { + type = "prompt"; + } + + return { + id: nextId(), + ts: clampTs(ts), + agent: "hermes", + type, + sessionId: m.session_id, + tool: toolName, + riskScore: riskOf(type), + summary: hermesSummaryFor(type, m, toolName), + details: { + source, + fullText: m.content ?? undefined, + thinking: m.reasoning ?? undefined, + toolInput, + toolUseId: m.tool_call_id ?? undefined, + }, + }; +} + +function hermesSummaryFor(type: EventType, m: HermesMessage, toolName?: string): string | undefined { + if (type === "tool_call") return toolName ? `${toolName}(…)` : "tool_call"; + if (!m.content) return undefined; + const oneline = m.content.replace(/\s+/g, " ").trim(); + if (oneline.length <= 140) return oneline; + return oneline.slice(0, 137) + "..."; +} + +export function startHermesAdapter(sink: Emit): () => void { + const emit = typeof sink === "function" ? sink : sink.emit; + const dbPath = resolveHermesDbPath(); + + // Hermes isn't installed → silent no-op (same convention as openclaw + // when ~/.openclaw doesn't exist). + if (!existsSync(dbPath)) return () => {}; + + let db: DB | null = null; + let lastMessageId = 0; + const seenSessionIds = new Set<string>(); + const openSessionIds = new Set<string>(); + let closed = false; + + function openDb(): DB | null { + try { + const d = new Database(dbPath, { readonly: true, fileMustExist: true }); + d.pragma("journal_mode = WAL"); + d.pragma("busy_timeout = 2000"); + return d; + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] hermes adapter: cannot open db:", err); + return null; + } + } + + function bootstrap(): void { + if (!db) return; + try { + // Backfill the most recent N messages so the UI shows hermes + // history on boot, same as claude-code/codex/gemini adapters do. + // 2000 is enough to cover recent activity without flooding the + // ring buffer. + const HERMES_BACKFILL = 2_000; + const row = db.prepare("SELECT COALESCE(MAX(id), 0) AS mx FROM messages").get() as { mx: number } | undefined; + const maxId = row?.mx ?? 0; + lastMessageId = Math.max(0, maxId - HERMES_BACKFILL); + // Don't pre-seed seenSessionIds — let the first poll emit + // session_start for each so they show up in the timeline too. + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] hermes bootstrap failed:", err); + } + } + + function pollAndEmit(): void { + if (!db || closed) return; + + try { + const newSessions = db + .prepare( + "SELECT id, source, user_id, model, parent_session_id, started_at, ended_at, end_reason, " + + "input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, " + + "actual_cost_usd, estimated_cost_usd " + + "FROM sessions", + ) + .all() as HermesSession[]; + + for (const s of newSessions) { + if (!seenSessionIds.has(s.id)) { + seenSessionIds.add(s.id); + emit(translateHermesSessionStart(s, dbPath)); + if (s.ended_at === null) openSessionIds.add(s.id); + else emit(translateHermesSessionEnd(s, dbPath)); + } else if (openSessionIds.has(s.id) && s.ended_at !== null) { + openSessionIds.delete(s.id); + emit(translateHermesSessionEnd(s, dbPath)); + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] hermes sessions poll failed:", err); + } + + try { + const rows = db + .prepare( + "SELECT id, session_id, role, content, tool_call_id, tool_calls, tool_name, " + + "timestamp, token_count, finish_reason, reasoning " + + "FROM messages WHERE id > ? ORDER BY id LIMIT 500", + ) + .all(lastMessageId) as HermesMessage[]; + + for (const m of rows) { + lastMessageId = Math.max(lastMessageId, m.id); + const evt = translateHermesMessage(m, dbPath); + if (evt) emit(evt); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] hermes messages poll failed:", err); + } + } + + db = openDb(); + if (!db) return () => {}; + bootstrap(); + + pollAndEmit(); + + // Watch the db + WAL sidecar. No awaitWriteFinish — we're polling SQLite, + // not reading file bytes, so we don't need the file to be "stable" before + // firing. fsevents is also slow for SQLite WAL writes (the main .db + // mtime only moves on checkpoint), so the 2s safety-net poll below is + // the real latency ceiling users see. + const watchPaths = [dbPath, dbPath + "-wal"]; + const watcher = chokidar.watch(watchPaths, { ignoreInitial: true }); + watcher.on("change", () => pollAndEmit()); + watcher.on("add", () => pollAndEmit()); + + const poller = setInterval(() => pollAndEmit(), 2_000); + + return (): void => { + closed = true; + clearInterval(poller); + try { + watcher.close(); + } catch { + // chokidar close errors are non-fatal + } + if (db) { + try { + db.close(); + } catch { + // better-sqlite3 close errors are non-fatal + } + db = null; + } + }; +} + +// Exported for tests. +export const _HERMES_INTERNAL = { + DEFAULT_DB_PATH, +}; diff --git a/src/adapters/hooks-dedup.ts b/src/adapters/hooks-dedup.ts new file mode 100644 index 0000000..916e11b --- /dev/null +++ b/src/adapters/hooks-dedup.ts @@ -0,0 +1,71 @@ +import type { AgentEvent, EventSink } from "../schema.js"; + +/** When the same logical Claude Code event arrives via both the + * hooks adapter and the JSONL tail, hooks win — they're real-time and + * authoritative. The JSONL emit is suppressed on a 5-second + * correlation window keyed on `<session_id>:<tool_use_id>`. + * + * Module-global because both adapters need to talk to the same + * registry. The 5-second window is purely time-based: stale entries + * are evicted lazily on access. */ + +const WINDOW_MS = 5000; + +const seen = new Map<string, number>(); + +export function markHookSeen(signature: string): void { + seen.set(signature, Date.now()); + // Keep the registry small — drop anything older than 60s on every + // write. 60s is a generous upper bound (10x the dedup window). + if (seen.size > 1000) evictOlderThan(60_000); +} + +export function wasHookSeen(signature: string): boolean { + const t = seen.get(signature); + if (t == null) return false; + if (Date.now() - t > WINDOW_MS) { + seen.delete(signature); + return false; + } + return true; +} + +export function clearHookDedup(): void { + seen.clear(); +} + +function evictOlderThan(ms: number): void { + const cutoff = Date.now() - ms; + for (const [sig, t] of seen) { + if (t < cutoff) seen.delete(sig); + } +} + +/** Build the dedup signature for a tool-related Claude event. Returns + * null when there's nothing to dedup against (no session id or no + * tool_use_id). */ +export function toolSignature( + sessionId: string | undefined, + toolUseId: string | undefined, +): string | null { + if (!sessionId || !toolUseId) return null; + return `${sessionId}:${toolUseId}`; +} + +/** Wrap an EventSink so JSONL claude-code tool events are dropped + * when a hook adapter has already emitted the same logical event. + * The hooks adapter itself stamps `details.source = "hooks"`; we + * use that as the bypass signal so hook events never dedup against + * themselves. */ +export function withClaudeHookDedup(inner: EventSink): EventSink { + return { + emit: (e: AgentEvent) => { + if (e.agent === "claude-code" && e.details?.source !== "hooks") { + const sig = toolSignature(e.sessionId, e.details?.toolUseId); + if (sig && wasHookSeen(sig)) return; // suppressed + } + inner.emit(e); + }, + enrich: inner.enrich, + }; +} diff --git a/src/adapters/openclaw.test.ts b/src/adapters/openclaw.test.ts index 741bfbb..0b474db 100644 --- a/src/adapters/openclaw.test.ts +++ b/src/adapters/openclaw.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { translateSession, translateAudit } from "./openclaw.js"; +import { describe, expect, it, beforeEach } from "vitest"; +import type { EventDetails, EventSink } from "../schema.js"; +import { + translateSession, + translateAudit, + handleOpenClawToolResult, + _resetOpenClawToolPairing, + _registerOpenClawPendingForTest, +} from "./openclaw.js"; describe("translateSession", () => { it("tags events with agent=openclaw and sub-agent in tool field", () => { @@ -61,3 +68,210 @@ describe("translateAudit", () => { expect(e?.riskScore).toBe(10); }); }); + +describe("AUR-217: OpenClaw toolCall + toolResult pairing", () => { + beforeEach(() => { + _resetOpenClawToolPairing(); + }); + + it("extracts a toolCall (camelCase) from an assistant message and exposes its id as toolUseId", () => { + const msg = { + type: "message", + timestamp: "2026-04-25T19:11:26.371Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "…" }, + { + type: "toolCall", + id: "mfeipxl0", + name: "exec", + arguments: { command: "echo ok" }, + }, + { type: "text", text: "" }, + ], + }, + }; + const e = translateSession(msg, "agentwatch-daily", "sess-1"); + expect(e?.type).toBe("shell_exec"); + expect(e?.tool).toBe("openclaw:agentwatch-daily:exec"); + expect(e?.cmd).toBe("echo ok"); + expect(e?.details?.toolUseId).toBe("mfeipxl0"); + }); + + it("falls back to the file/cmd field synonyms when extracting paths", () => { + const msg = { + type: "message", + timestamp: "2026-04-25T19:11:27.000Z", + message: { + role: "assistant", + content: [ + { + type: "toolCall", + id: "wr-1", + name: "write", + arguments: { file: "output/x.csv", content: "a,b,c\n" }, + }, + ], + }, + }; + const e = translateSession(msg, "research", "sess-2"); + expect(e?.type).toBe("file_write"); + expect(e?.path).toBe("output/x.csv"); + expect(e?.details?.toolUseId).toBe("wr-1"); + }); + + it("pairs a toolResult turn with the matching pending toolCall via enrich", () => { + const recorded: Array<{ id: string; patch: Partial<EventDetails> }> = []; + const sink: EventSink = { + emit: () => {}, + enrich: (id, patch) => recorded.push({ id, patch }), + }; + // Simulate the adapter having already emitted the tool_use event + // and registered the pending callId by calling translateSession + + // poking the pairing map. We do that here by hand: call the result + // handler with no pending entry first → orphan. + const earlyResult = { + type: "message", + timestamp: "2026-04-25T19:11:30.000Z", + message: { + role: "toolResult", + toolCallId: "abc", + toolName: "exec", + content: [{ type: "text", text: "ENV sourced" }], + details: { exitCode: 0, durationMs: 5 }, + isError: false, + timestamp: 1777144290000, + }, + }; + handleOpenClawToolResult(earlyResult, sink.enrich); + // No pending → enriches nothing yet; the orphan is held internally. + expect(recorded).toHaveLength(0); + + // Now the tool_use comes through. The translateSession path is + // tested above; here we simulate the adapter side by registering + // the pending entry via a synthetic toolCall message. + // (We don't have a public 'register pending' API; instead we + // confirm the orphan path by feeding the result a SECOND time after + // a pending entry has been seeded via a real round-trip.) + _resetOpenClawToolPairing(); + + const callMsg = { + type: "message", + timestamp: "2026-04-25T19:11:26.000Z", + message: { + role: "assistant", + content: [ + { + type: "toolCall", + id: "abc", + name: "exec", + arguments: { command: "echo hi" }, + }, + ], + }, + }; + // Translate (not through processSession), then register the + // pending pairing manually by re-invoking handleOpenClawToolResult + // after the call had been emitted in production. We inject the + // pairing through the sink: the translator returns the event with + // toolUseId, the adapter is what stores pending. So for this unit + // test, we test handleOpenClawToolResult's *enrich* path by first + // priming the orphan map (earlyResult above) — the symmetric path: + const event = translateSession(callMsg, "research", "sess-3"); + expect(event?.details?.toolUseId).toBe("abc"); + }); + + it("enriches with the matched toolResult content + duration once paired", () => { + const enrichments: Array<{ id: string; patch: Partial<EventDetails> }> = + []; + const sink: EventSink = { + emit: () => {}, + enrich: (id, patch) => enrichments.push({ id, patch }), + }; + // Seed a pending tool_use → eventId mapping (the adapter would + // normally do this on the assistant turn carrying the toolCall). + _registerOpenClawPendingForTest( + "abc-1", + "ev-call-1", + "2026-04-25T19:11:26.000Z", + ); + + handleOpenClawToolResult( + { + type: "message", + timestamp: "2026-04-25T19:11:30.000Z", + message: { + role: "toolResult", + toolCallId: "abc-1", + toolName: "exec", + content: [{ type: "text", text: "ENV sourced" }], + details: { exitCode: 0, durationMs: 5 }, + isError: false, + }, + }, + sink.enrich, + ); + + expect(enrichments).toHaveLength(1); + expect(enrichments[0]?.id).toBe("ev-call-1"); + expect(enrichments[0]?.patch.toolResult).toBe("ENV sourced"); + expect(enrichments[0]?.patch.toolError).toBe(false); + // Adapter prefers the explicit details.durationMs when provided. + expect(enrichments[0]?.patch.durationMs).toBe(5); + }); + + it("flags toolError=true when the toolResult message has isError=true", () => { + const enrichments: Array<{ id: string; patch: Partial<EventDetails> }> = + []; + const sink: EventSink = { + emit: () => {}, + enrich: (id, patch) => enrichments.push({ id, patch }), + }; + _registerOpenClawPendingForTest( + "fail-1", + "ev-fail-1", + "2026-04-25T19:11:26.000Z", + ); + handleOpenClawToolResult( + { + type: "message", + timestamp: "2026-04-25T19:11:27.000Z", + message: { + role: "toolResult", + toolCallId: "fail-1", + content: [{ type: "text", text: "command not found: foo" }], + isError: true, + }, + }, + sink.enrich, + ); + expect(enrichments[0]?.patch.toolError).toBe(true); + expect(enrichments[0]?.patch.toolResult).toContain("command not found"); + }); + + it("ignores non-toolResult message turns and unrelated lines", () => { + const enrichments: Array<{ id: string; patch: Partial<EventDetails> }> = + []; + const sink: EventSink = { + emit: () => {}, + enrich: (id, patch) => enrichments.push({ id, patch }), + }; + handleOpenClawToolResult( + { type: "session", id: "x", cwd: "/tmp" }, + sink.enrich, + ); + handleOpenClawToolResult( + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "hi" }], + }, + }, + sink.enrich, + ); + handleOpenClawToolResult(null, sink.enrich); + expect(enrichments).toHaveLength(0); + }); +}); diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts index b51de4c..f7ab812 100644 --- a/src/adapters/openclaw.ts +++ b/src/adapters/openclaw.ts @@ -1,22 +1,121 @@ import chokidar from "chokidar"; -import { createReadStream, existsSync, statSync } from "node:fs"; -import { createInterface } from "node:readline"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { basename, join, sep } from "node:path"; import { homedir } from "node:os"; import type { AgentEvent, EventType } from "../schema.js"; -import { riskOf } from "../schema.js"; +import { clampTs, riskOf } from "../schema.js"; import { nextId } from "../util/ids.js"; +import { readNewlineTerminatedLines } from "../util/jsonl-stream.js"; +import { createParseErrorTracker } from "../util/parse-errors.js"; +import { + classifySessionKey, + type ScheduledMarker, +} from "../util/openclaw-cron.js"; -type Emit = (e: AgentEvent) => void; +import type { EventSink } from "../schema.js"; + +type Emit = EventSink | ((e: AgentEvent) => void); interface FileCursor { offset: number; } -export function startOpenClawAdapter(emit: Emit): () => void { +// Shared across adapter lifetime: session_start entries tell us the cwd, +// later messages in the same session inherit it so we can tag events +// with a project label. +const sessionCwd = new Map<string, string>(); + +// AUR-217: pair tool_use events with their toolResult turn so the TUI +// can show stdout / errors / duration alongside the call. Sized to +// match the Claude adapter's ceiling — sessions that crash mid-turn +// would otherwise leak callIds. Shared across adapter lifetime so +// pairing survives backfill ordering quirks. +const MAX_PENDING_OPENCLAW_CALLS = 5000; +const pendingOpenClawCalls = new Map< + string, + { eventId: string; ts: string } +>(); +const orphanOpenClawResults = new Map< + string, + { ts: string; content: string; isError: boolean } +>(); + +function capMap<K, V>(m: Map<K, V>, max: number): void { + while (m.size > max) { + const first = m.keys().next().value; + if (first === undefined) break; + m.delete(first); + } +} + +export function _resetOpenClawToolPairing(): void { + pendingOpenClawCalls.clear(); + orphanOpenClawResults.clear(); +} + +/** @internal Test-only: seed a pending tool_use → eventId mapping so + * `handleOpenClawToolResult` can be exercised end-to-end without + * needing the full processSession pipeline. */ +export function _registerOpenClawPendingForTest( + callId: string, + eventId: string, + ts: string, +): void { + pendingOpenClawCalls.set(callId, { eventId, ts }); +} + +// AUR-205/206: cache (sessionId → ScheduledMarker) so events from a +// cron-spawned or heartbeat-triggered session pick up `details.scheduled`. +// Filled lazily by reading each agent's sessions.json the first time +// we touch one of its session files. +const scheduledBySessionId = new Map<string, ScheduledMarker>(); +const sessionsJsonRead = new Set<string>(); + +function loadScheduledMarkers(file: string): void { + // Resolve to .../agents/<agentId>/sessions/sessions.json + const dir = file.split(sep).slice(0, -1).join(sep); + const jsonPath = join(dir, "sessions.json"); + if (sessionsJsonRead.has(jsonPath)) return; + sessionsJsonRead.add(jsonPath); + let raw: string; + try { + raw = readFileSync(jsonPath, "utf8"); + } catch { + return; + } + let doc: unknown; + try { + doc = JSON.parse(raw); + } catch { + return; + } + if (!doc || typeof doc !== "object") return; + for (const [sessionKey, entryRaw] of Object.entries( + doc as Record<string, unknown>, + )) { + const entry = (entryRaw ?? {}) as Record<string, unknown>; + const marker = classifySessionKey(sessionKey, entry); + if (!marker) continue; + const sid = entry.sessionId; + if (typeof sid === "string") scheduledBySessionId.set(sid, marker); + } +} + +export function _resetOpenClawScheduledCache(): void { + scheduledBySessionId.clear(); + sessionsJsonRead.clear(); +} + +export function startOpenClawAdapter(sink: Emit): () => void { + const normalized: EventSink = + typeof sink === "function" + ? { emit: sink, enrich: () => {} } + : sink; + const emit = normalized.emit; const root = join(homedir(), ".openclaw"); if (!existsSync(root)) return () => {}; + const parseErrors = createParseErrorTracker("openclaw", normalized); const cursors = new Map<string, FileCursor>(); const stoppers: Array<() => void> = []; @@ -32,7 +131,7 @@ export function startOpenClawAdapter(emit: Emit): () => void { }); const handleSession = (f: string, initial: boolean) => { if (!sessionRe.test(f)) return; - processSession(f, initial, cursors, emit); + processSession(f, initial, cursors, normalized, parseErrors); }; sessionsWatcher.on("add", (f) => handleSession(f, true)); sessionsWatcher.on("change", (f) => handleSession(f, false)); @@ -47,8 +146,12 @@ export function startOpenClawAdapter(emit: Emit): () => void { persistent: true, ignoreInitial: false, }); - auditWatcher.on("add", (f) => processAudit(f, true, cursors, emit)); - auditWatcher.on("change", (f) => processAudit(f, false, cursors, emit)); + auditWatcher.on("add", (f) => + processAudit(f, true, cursors, emit, parseErrors), + ); + auditWatcher.on("change", (f) => + processAudit(f, false, cursors, emit, parseErrors), + ); auditWatcher.on("error", swallow); stoppers.push(() => { void auditWatcher.close(); @@ -63,33 +166,145 @@ function processSession( file: string, startFromEnd: boolean, cursors: Map<string, FileCursor>, - emit: Emit, + sink: EventSink, + parseErrors: { recordFailure(sessionKey: string, line: string): void }, ) { const subAgent = extractSubAgent(file); const sessionId = basename(file, ".jsonl"); + // Lazy-load the per-agent sessions.json so we know whether this + // session was spawned by cron or by a heartbeat run. + loadScheduledMarkers(file); + const marker = scheduledBySessionId.get(sessionId); streamLines(file, startFromEnd, cursors, (line) => { let obj: unknown; try { obj = JSON.parse(line); } catch { + parseErrors.recordFailure(sessionId, line); return; } + // AUR-217: harvest toolResult turns first — they carry stdout + + // exitCode + isError for an earlier toolCall and must enrich the + // existing tool_use event rather than emit anything new. + handleOpenClawToolResult(obj, sink.enrich); + const event = translateSession(obj, subAgent, sessionId); - if (event) emit(event); + if (!event) return; + if (marker) { + event.details = { + ...(event.details ?? {}), + scheduled: { + kind: marker.kind, + jobId: marker.jobId, + agentId: marker.agentId, + runId: marker.runId, + }, + }; + } + sink.emit(event); + + // If this is a tool_use event, register it (or pair if its result + // already arrived during backfill replay). + const callId = event.details?.toolUseId; + if (callId && orphanOpenClawResults.has(callId)) { + const orphan = orphanOpenClawResults.get(callId)!; + orphanOpenClawResults.delete(callId); + sink.enrich(event.id, { + toolResult: orphan.content, + toolError: orphan.isError, + durationMs: Math.max( + 0, + new Date(orphan.ts).getTime() - new Date(event.ts).getTime(), + ), + }); + } else if (callId) { + pendingOpenClawCalls.set(callId, { eventId: event.id, ts: event.ts }); + capMap(pendingOpenClawCalls, MAX_PENDING_OPENCLAW_CALLS); + } }); } +const MAX_TOOL_RESULT_BYTES = 256 * 1024; + +function capBytes(s: string, max = MAX_TOOL_RESULT_BYTES): string { + if (s.length <= max) return s; + const truncated = s.length - max; + return s.slice(0, max) + `\n\n… [${truncated.toLocaleString()} bytes truncated]`; +} + +function flattenToolResultContent(content: unknown): string { + if (typeof content === "string") return capBytes(content); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if (typeof c === "string") { + parts.push(c); + } else if (typeof c === "object" && c !== null) { + const rec = c as Record<string, unknown>; + if (typeof rec.text === "string") parts.push(rec.text); + } + } + return capBytes(parts.join("\n")); +} + +/** OpenClaw tool results are dedicated `message.role:"toolResult"` turns + * that carry the original toolCallId, the textual output, and a + * details.{exitCode,durationMs,status} envelope. We pair them with the + * pending tool_use event by toolCallId and enrich. AUR-217. */ +export function handleOpenClawToolResult( + obj: unknown, + enrich: EventSink["enrich"], +): void { + if (!obj || typeof obj !== "object") return; + const o = obj as Record<string, unknown>; + if (o.type !== "message") return; + const msg = o.message as Record<string, unknown> | undefined; + if (!msg || msg.role !== "toolResult") return; + const callId = + typeof msg.toolCallId === "string" ? msg.toolCallId : undefined; + if (!callId) return; + const isError = msg.isError === true; + const content = flattenToolResultContent(msg.content); + const ts = + (typeof o.timestamp === "string" && o.timestamp) || + (typeof msg.timestamp === "number" + ? new Date(msg.timestamp).toISOString() + : new Date().toISOString()); + + const pending = pendingOpenClawCalls.get(callId); + if (pending) { + pendingOpenClawCalls.delete(callId); + const details = msg.details as Record<string, unknown> | undefined; + const explicitDuration = + typeof details?.durationMs === "number" ? details.durationMs : undefined; + const computedDuration = Math.max( + 0, + new Date(ts).getTime() - new Date(pending.ts).getTime(), + ); + enrich(pending.eventId, { + toolResult: content, + toolError: isError, + durationMs: explicitDuration ?? computedDuration, + }); + } else { + orphanOpenClawResults.set(callId, { ts, content, isError }); + capMap(orphanOpenClawResults, 1000); + } +} + function processAudit( file: string, startFromEnd: boolean, cursors: Map<string, FileCursor>, - emit: Emit, + emit: (e: AgentEvent) => void, + parseErrors: { recordFailure(sessionKey: string, line: string): void }, ) { streamLines(file, startFromEnd, cursors, (line) => { let obj: unknown; try { obj = JSON.parse(line); } catch { + parseErrors.recordFailure("config-audit", line); return; } const event = translateAudit(obj); @@ -97,14 +312,15 @@ function processAudit( }); } -const BACKFILL_BYTES = 64 * 1024; // last 64KB on first add +/** Same backfill window as Claude adapter; see its comment. */ +const BACKFILL_BYTES = 4 * 1024 * 1024; function streamLines( file: string, isInitialAdd: boolean, cursors: Map<string, FileCursor>, onLine: (line: string) => void, -) { +): void { const size = safeSize(file); let cursor = cursors.get(file); if (!cursor) { @@ -115,26 +331,17 @@ function streamLines( if (size <= cursor.offset) return; const start = cursor.offset; - const stream = createReadStream(file, { + const { lines, consumed } = readNewlineTerminatedLines( + file, start, - end: size - 1, - encoding: "utf8", - }); - let consumed = 0; - let skippedFirst = false; - const rl = createInterface({ input: stream, crlfDelay: Infinity }); - rl.on("line", (line) => { - consumed += Buffer.byteLength(line, "utf8") + 1; - // If we started mid-file, drop the first (likely partial) line - if (isInitialAdd && start > 0 && !skippedFirst) { - skippedFirst = true; - return; - } + size - 1, + ); + cursor.offset = start + consumed; + for (let i = 0; i < lines.length; i++) { + if (i === 0 && isInitialAdd && start > 0) continue; + const line = lines[i]!; if (line.trim()) onLine(line); - }); - rl.on("close", () => { - cursor!.offset = start + consumed; - }); + } } function swallow(err: unknown): void { @@ -161,6 +368,36 @@ function extractSubAgent(file: string): string { return "unknown"; } +/** OpenClaw records usage on the assistant message directly: + * { input, output, cacheRead, cacheWrite, totalTokens, cost: {…} } + * + * Fields map cleanly onto our schema except cacheWrite → cacheCreate. */ +export function extractOpenClawUsage( + msg: Record<string, unknown> | undefined, +): { input: number; cacheCreate: number; cacheRead: number; output: number } | null { + const u = msg?.usage; + if (!u || typeof u !== "object") return null; + const o = u as Record<string, unknown>; + const n = (v: unknown): number => (typeof v === "number" ? v : 0); + const input = n(o.input); + const output = n(o.output); + const cacheRead = n(o.cacheRead); + const cacheCreate = n(o.cacheWrite); + if (input + output + cacheRead + cacheCreate === 0) return null; + return { input, cacheCreate, cacheRead, output }; +} + +export function extractOpenClawCost( + msg: Record<string, unknown> | undefined, +): number | null { + const u = msg?.usage; + if (!u || typeof u !== "object") return null; + const c = (u as Record<string, unknown>).cost; + if (!c || typeof c !== "object") return null; + const total = (c as Record<string, unknown>).total; + return typeof total === "number" ? total : null; +} + export function translateSession( obj: unknown, subAgent: string, @@ -168,27 +405,41 @@ export function translateSession( ): AgentEvent | null { if (!obj || typeof obj !== "object") return null; const o = obj as Record<string, unknown>; - const ts = + const ts = clampTs( (typeof o.timestamp === "string" && o.timestamp) || - new Date().toISOString(); + new Date().toISOString(), + ); const t = o.type; + const projectLabel = () => { + const cwd = sessionCwd.get(sessionId); + if (!cwd) return ""; + const b = cwd.split("/").filter(Boolean).pop(); + return b ? `[${b}] ` : ""; + }; + const base = ( type: EventType, fields: Partial<AgentEvent> = {}, - ): AgentEvent => ({ - id: nextId(), - ts, - agent: "openclaw", - type, - tool: `openclaw:${subAgent}`, - sessionId, - riskScore: riskOf(type, fields.path, fields.cmd), - ...fields, - }); + ): AgentEvent => { + const prefix = projectLabel(); + const rawSummary = fields.summary ?? ""; + return { + id: nextId(), + ts, + agent: "openclaw", + type, + tool: `openclaw:${subAgent}`, + sessionId, + riskScore: riskOf(type, fields.path, fields.cmd), + ...fields, + summary: rawSummary ? prefix + rawSummary : prefix + type, + }; + }; if (t === "session") { const cwd = typeof o.cwd === "string" ? o.cwd : undefined; + if (cwd) sessionCwd.set(sessionId, cwd); return base("session_start", { path: cwd, summary: `openclaw/${subAgent} session started${cwd ? ` in ${cwd}` : ""}`, @@ -210,9 +461,16 @@ export function translateSession( const content = msg?.content; const text = extractText(content); if (role === "user") { - return base("prompt", { summary: truncate(text) }); + return base("prompt", { + summary: truncate(text), + details: { fullText: text }, + }); } if (role === "assistant") { + const usage = extractOpenClawUsage(msg); + const model = + typeof msg?.model === "string" ? msg.model : undefined; + const precomputedCost = extractOpenClawCost(msg); const toolUse = extractToolUse(content); if (toolUse) { const type = inferToolType(toolUse.name); @@ -221,9 +479,25 @@ export function translateSession( path: toolUse.path, cmd: toolUse.cmd, summary: truncate(toolUse.summary), + details: { + toolInput: toolUse.input, + ...(toolUse.id ? { toolUseId: toolUse.id } : {}), + ...(usage ? { usage } : {}), + ...(precomputedCost != null ? { cost: precomputedCost } : {}), + ...(model ? { model } : {}), + }, }); } - return base("response", { summary: truncate(text) }); + if (!text) return null; // suppress empty assistant messages + return base("response", { + summary: truncate(text), + details: { + fullText: text, + ...(usage ? { usage } : {}), + ...(precomputedCost != null ? { cost: precomputedCost } : {}), + ...(model ? { model } : {}), + }, + }); } } @@ -233,8 +507,9 @@ export function translateSession( export function translateAudit(obj: unknown): AgentEvent | null { if (!obj || typeof obj !== "object") return null; const o = obj as Record<string, unknown>; - const ts = - (typeof o.ts === "string" && o.ts) || new Date().toISOString(); + const ts = clampTs( + (typeof o.ts === "string" && o.ts) || new Date().toISOString(), + ); const event = typeof o.event === "string" ? o.event : "config.event"; const configPath = typeof o.configPath === "string" ? o.configPath : undefined; const cwd = typeof o.cwd === "string" ? o.cwd : undefined; @@ -274,29 +549,42 @@ interface ToolUse { path?: string; cmd?: string; summary: string; + input: Record<string, unknown>; + id?: string; } function extractToolUse(content: unknown): ToolUse | null { if (!Array.isArray(content)) return null; for (const c of content) { - if ( - typeof c === "object" && - c !== null && - (c as { type?: string }).type === "tool_use" - ) { - const r = c as Record<string, unknown>; - const name = typeof r.name === "string" ? r.name : "unknown"; - const input = (r.input ?? {}) as Record<string, unknown>; - const path = - typeof input.file_path === "string" - ? input.file_path - : typeof input.path === "string" - ? input.path + if (typeof c !== "object" || c === null) continue; + const r = c as Record<string, unknown>; + // Accept both the pure-Anthropic shape (`type: "tool_use"`, + // `input: {...}`) and OpenClaw's native shape (`type: "toolCall"`, + // `arguments: {...}`). The latter is what real ~/.openclaw sessions + // actually contain — AUR-217. + if (r.type !== "tool_use" && r.type !== "toolCall") continue; + const name = typeof r.name === "string" ? r.name : "unknown"; + const id = typeof r.id === "string" ? r.id : undefined; + const input = + ((r.input as Record<string, unknown> | undefined) ?? + (r.arguments as Record<string, unknown> | undefined) ?? + {}) as Record<string, unknown>; + const path = + typeof input.file_path === "string" + ? input.file_path + : typeof input.path === "string" + ? input.path + : typeof input.file === "string" + ? input.file : undefined; - const cmd = typeof input.command === "string" ? input.command : undefined; - const summary = cmd ?? path ?? name; - return { name, path, cmd, summary }; - } + const cmd = + typeof input.command === "string" + ? input.command + : typeof input.cmd === "string" + ? input.cmd + : undefined; + const summary = cmd ?? path ?? name; + return { name, path, cmd, summary, input, id }; } return null; } diff --git a/src/adapters/openclaw.usage.test.ts b/src/adapters/openclaw.usage.test.ts new file mode 100644 index 0000000..c2c67c2 --- /dev/null +++ b/src/adapters/openclaw.usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + extractOpenClawUsage, + extractOpenClawCost, +} from "./openclaw.js"; + +describe("extractOpenClawUsage", () => { + it("maps cacheWrite → cacheCreate and preserves input/output/cacheRead", () => { + const u = extractOpenClawUsage({ + usage: { + input: 100, + output: 50, + cacheRead: 30, + cacheWrite: 10, + totalTokens: 190, + }, + }); + expect(u).toEqual({ + input: 100, + cacheCreate: 10, + cacheRead: 30, + output: 50, + }); + }); + + it("returns null when no usage present", () => { + expect(extractOpenClawUsage({})).toBeNull(); + expect(extractOpenClawUsage(undefined)).toBeNull(); + }); + + it("returns null when every field is zero", () => { + expect( + extractOpenClawUsage({ + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }), + ).toBeNull(); + }); +}); + +describe("extractOpenClawCost", () => { + it("returns the precomputed total cost when present", () => { + expect( + extractOpenClawCost({ + usage: { cost: { total: 0.069224 } }, + }), + ).toBeCloseTo(0.069224); + }); + + it("returns null when cost is missing", () => { + expect(extractOpenClawCost({ usage: {} })).toBeNull(); + expect(extractOpenClawCost({})).toBeNull(); + }); +}); diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts new file mode 100644 index 0000000..ab164e5 --- /dev/null +++ b/src/adapters/registry.ts @@ -0,0 +1,106 @@ +import type { EventSink } from "../schema.js"; +import type { CursorStatus } from "./cursor.js"; +import { startClaudeAdapter } from "./claude-code.js"; +import { startOpenClawAdapter } from "./openclaw.js"; +import { startCursorAdapter } from "./cursor.js"; +import { startGeminiAdapter } from "./gemini.js"; +import { startCodexAdapter } from "./codex.js"; +import { startHermesAdapter } from "./hermes.js"; +import { startFsAdapter } from "./fs-watcher.js"; + +/** + * Adapter registry. One row per data source. Keeps App.tsx free of the + * "add adapter N+1" churn — every new adapter drops into this list and + * App.tsx loops over it. + * + * Two flavors: + * - Adapters that take only a sink and return a stop fn + * - Adapters that also need the workspace root + * - Cursor is the outlier — it also returns a status object + * that the UI uses in the permissions view + */ + +export interface StartedAdapter { + name: string; + stop: () => void; + /** Only set for cursor today. */ + status?: CursorStatus; +} + +export function startAllAdapters( + sink: EventSink, + workspace: string, +): StartedAdapter[] { + const started: StartedAdapter[] = []; + + started.push({ + name: "claude-code", + stop: wrap(() => startClaudeAdapter(sink), "claude-code"), + }); + started.push({ + name: "openclaw", + stop: wrap(() => startOpenClawAdapter(sink), "openclaw"), + }); + + const cursor = safeStart(() => startCursorAdapter(workspace, sink), "cursor"); + if (cursor) { + started.push({ + name: "cursor", + stop: cursor.stop, + status: cursor.status, + }); + } + + started.push({ + name: "gemini", + stop: wrap(() => startGeminiAdapter(sink), "gemini"), + }); + started.push({ + name: "codex", + stop: wrap(() => startCodexAdapter(sink), "codex"), + }); + started.push({ + name: "hermes", + stop: wrap(() => startHermesAdapter(sink), "hermes"), + }); + started.push({ + name: "fs-watcher", + stop: wrap(() => startFsAdapter(workspace, sink), "fs-watcher"), + }); + + return started; +} + +export function stopAllAdapters(adapters: StartedAdapter[]): void { + for (const a of adapters) { + try { + a.stop(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[agentwatch] adapter ${a.name} stop failed:`, err); + } + } +} + +/** Adapter start callbacks can throw on a bad environment (missing home + * dir, permission error). Isolate every start so one bad adapter + * doesn't take the process down. */ +function wrap(start: () => () => void, name: string): () => void { + try { + return start(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[agentwatch] adapter ${name} failed to start:`, err); + return () => {}; + } +} + +function safeStart<T>(start: () => T, name: string): T | null { + try { + return start(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[agentwatch] adapter ${name} failed to start:`, err); + return null; + } +} diff --git a/src/classify/activity.test.ts b/src/classify/activity.test.ts new file mode 100644 index 0000000..0ca70eb --- /dev/null +++ b/src/classify/activity.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEvent } from "../schema.js"; +import { + ACTIVITY_CATEGORIES, + classifyEvent, + type ActivityCategory, +} from "./activity.js"; +import { withClassifier } from "./sink.js"; + +let id = 0; + +function evt(over: Partial<AgentEvent>): AgentEvent { + return { + id: `e-${++id}`, + ts: new Date().toISOString(), + agent: over.agent ?? "claude-code", + type: over.type ?? "tool_call", + riskScore: over.riskScore ?? 1, + path: over.path, + cmd: over.cmd, + tool: over.tool, + summary: over.summary, + sessionId: over.sessionId, + promptId: over.promptId, + details: over.details, + }; +} + +interface Case { + name: string; + expect: ActivityCategory; + event: AgentEvent; +} + +const CASES: Case[] = [ + // ---- coding ---- + { + name: "writing a TypeScript source file", + expect: "coding", + event: evt({ type: "file_write", path: "src/api/auth.ts", tool: "Write" }), + }, + { + name: "editing a Python source file", + expect: "coding", + event: evt({ type: "file_change", path: "app/handlers/login.py" }), + }, + { + name: "MultiEdit on a Go file", + expect: "coding", + event: evt({ type: "file_write", path: "internal/server/routes.go", tool: "MultiEdit" }), + }, + { + name: "git commit shell exec", + expect: "coding", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "git commit -m feat: add login" }), + }, + + // ---- testing ---- + { + name: "writing a vitest test file", + expect: "testing", + event: evt({ type: "file_write", path: "src/api/auth.test.ts", tool: "Write" }), + }, + { + name: "writing a pytest test under tests/", + expect: "testing", + event: evt({ type: "file_write", path: "tests/test_login.py" }), + }, + { + name: "running npm test", + expect: "testing", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "npm test" }), + }, + { + name: "running pytest", + expect: "testing", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "pytest -k login" }), + }, + + // ---- docs ---- + { + name: "editing a markdown doc", + expect: "docs", + event: evt({ type: "file_write", path: "docs/features/api.md" }), + }, + { + name: "editing the README", + expect: "docs", + event: evt({ type: "file_write", path: "README.md" }), + }, + { + name: "editing CHANGELOG", + expect: "docs", + event: evt({ type: "file_write", path: "CHANGELOG.md" }), + }, + + // ---- config ---- + { + name: "editing tsconfig.json", + expect: "config", + event: evt({ type: "file_write", path: "tsconfig.json" }), + }, + { + name: "editing a yaml config", + expect: "config", + event: evt({ type: "file_write", path: "k8s/deployment.yaml" }), + }, + { + name: "editing package.json", + expect: "config", + event: evt({ type: "file_write", path: "package.json" }), + }, + + // ---- debugging ---- + { + name: "prompt mentioning a stack trace", + expect: "debugging", + event: evt({ + type: "prompt", + details: { fullText: "I'm getting a stack trace when I call /login — can you fix this bug?" }, + }), + }, + { + name: "shell exec with tool error", + expect: "debugging", + event: evt({ + type: "shell_exec", + tool: "Bash", + cmd: "npm run build", + details: { toolError: true }, + }), + }, + + // ---- refactor ---- + { + name: "prompt asking to refactor", + expect: "refactor", + event: evt({ + type: "prompt", + details: { fullText: "please refactor this file to extract the validation logic into its own module" }, + }), + }, + { + name: "prompt asking to rename", + expect: "refactor", + event: evt({ + type: "prompt", + details: { fullText: "rename getUser to fetchUserById and move it to api/user.ts" }, + }), + }, + + // ---- exploration ---- + { + name: "Grep tool call", + expect: "exploration", + event: evt({ type: "tool_call", tool: "Grep" }), + }, + { + name: "reading a non-config source file", + expect: "exploration", + event: evt({ type: "file_read", path: "src/util/cost.ts" }), + }, + + // ---- research ---- + { + name: "WebFetch tool call", + expect: "research", + event: evt({ type: "tool_call", tool: "WebFetch" }), + }, + { + name: "WebSearch tool call", + expect: "research", + event: evt({ type: "tool_call", tool: "WebSearch" }), + }, + + // ---- review ---- + { + name: "git diff shell exec", + expect: "review", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "git diff main" }), + }, + { + name: "prompt asking to audit", + expect: "review", + event: evt({ + type: "prompt", + details: { fullText: "please audit this file for SQL injection vulnerabilities" }, + }), + }, + + // ---- devops ---- + { + name: "kubectl shell exec", + expect: "devops", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "kubectl get pods -n prod" }), + }, + { + name: "docker shell exec", + expect: "devops", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "docker compose up -d" }), + }, + { + name: "terraform shell exec", + expect: "devops", + event: evt({ type: "shell_exec", tool: "Bash", cmd: "terraform apply" }), + }, + + // ---- planning ---- + { + name: "long thinking block", + expect: "planning", + event: evt({ + type: "response", + details: { + thinking: "Let me think through this carefully. ".repeat(80), + }, + }), + }, + { + name: "compaction event", + expect: "planning", + event: evt({ type: "compaction" }), + }, + + // ---- chat ---- + { + name: "session_start", + expect: "chat", + event: evt({ type: "session_start" }), + }, + { + name: "empty assistant turn", + expect: "chat", + event: evt({ + type: "response", + details: { fullText: "Sure, here you go." }, + }), + }, +]; + +describe("classifier — synthetic case dataset", () => { + for (const c of CASES) { + it(c.name, () => { + expect(classifyEvent(c.event)).toBe(c.expect); + }); + } + + it("hits at least 75% top-1 agreement on the synthetic dataset", () => { + let matches = 0; + for (const c of CASES) { + if (classifyEvent(c.event) === c.expect) matches += 1; + } + const ratio = matches / CASES.length; + expect(ratio).toBeGreaterThanOrEqual(0.75); + }); + + it("returns one of the declared categories for any non-empty input", () => { + const valid = new Set<string>(ACTIVITY_CATEGORIES); + for (const c of CASES) { + expect(valid.has(classifyEvent(c.event))).toBe(true); + } + }); +}); + +describe("classifier — withClassifier sink wrapper", () => { + it("attaches details.category to events that don't already have one", () => { + const captured: AgentEvent[] = []; + const inner = { + emit: (e: AgentEvent) => captured.push(e), + enrich: () => undefined, + }; + const wrapped = withClassifier(inner); + wrapped.emit( + evt({ type: "file_write", path: "src/api.ts", tool: "Write" }), + ); + expect(captured).toHaveLength(1); + expect(captured[0]?.details?.category).toBe("coding"); + }); + + it("doesn't overwrite a category an upstream sink already set", () => { + const captured: AgentEvent[] = []; + const inner = { + emit: (e: AgentEvent) => captured.push(e), + enrich: () => undefined, + }; + const wrapped = withClassifier(inner); + wrapped.emit( + evt({ + type: "file_write", + path: "src/api.ts", + details: { category: "review" }, + }), + ); + expect(captured[0]?.details?.category).toBe("review"); + }); + + it("forwards enrich unchanged", () => { + const enriches: Array<{ id: string; patch: object }> = []; + const inner = { + emit: () => undefined, + enrich: (id: string, patch: object) => enriches.push({ id, patch }), + }; + const wrapped = withClassifier(inner); + wrapped.enrich("e-1", { toolResult: "ok" }); + expect(enriches).toEqual([{ id: "e-1", patch: { toolResult: "ok" } }]); + }); +}); diff --git a/src/classify/activity.ts b/src/classify/activity.ts new file mode 100644 index 0000000..beef303 --- /dev/null +++ b/src/classify/activity.ts @@ -0,0 +1,196 @@ +import type { AgentEvent } from "../schema.js"; + +export type ActivityCategory = + | "coding" + | "debugging" + | "exploration" + | "planning" + | "refactor" + | "testing" + | "docs" + | "chat" + | "config" + | "review" + | "devops" + | "research"; + +export const ACTIVITY_CATEGORIES: ActivityCategory[] = [ + "coding", + "debugging", + "exploration", + "planning", + "refactor", + "testing", + "docs", + "chat", + "config", + "review", + "devops", + "research", +]; + +/** Classify a single event into one of the activity categories. + * + * This is a heuristic ladder: each rule contributes a weighted score + * for one category, and the highest-scoring category wins. The + * categories are deliberately broad (matching CodeBurn's 13 buckets) + * so the resulting pie chart answers "where is my spend going?" not + * "what exact thing is the agent doing?" + * + * Heuristics are derived from observable signals only — tool name, + * file extension, command verb, and a small keyword set on prompt / + * response text. No ML dependency. When no rule fires we fall through + * to `chat` (the catch-all bucket for assistant turns with no tool use). */ +export function classifyEvent(event: AgentEvent): ActivityCategory { + const scores = scoreEvent(event); + let winner: ActivityCategory = "chat"; + let max = 0; + for (const cat of ACTIVITY_CATEGORIES) { + const s = scores[cat] ?? 0; + if (s > max) { + max = s; + winner = cat; + } + } + return winner; +} + +export function scoreEvent( + event: AgentEvent, +): Partial<Record<ActivityCategory, number>> { + const scores: Partial<Record<ActivityCategory, number>> = {}; + const add = (cat: ActivityCategory, n: number): void => { + scores[cat] = (scores[cat] ?? 0) + n; + }; + + const path = (event.path ?? "").toLowerCase(); + const cmd = (event.cmd ?? "").toLowerCase(); + const tool = (event.tool ?? "").toLowerCase(); + const summary = (event.summary ?? "").toLowerCase(); + const fullText = (event.details?.fullText ?? "").toLowerCase(); + const thinking = (event.details?.thinking ?? "").toLowerCase(); + const toolError = event.details?.toolError === true; + const text = `${summary} ${fullText} ${thinking}`; + + // ---- File-extension signals ---- + if (event.type === "file_write" || event.type === "file_change") { + if (isTestPath(path)) add("testing", 8); + else if (isDocPath(path)) add("docs", 8); + else if (isConfigPath(path)) add("config", 8); + else add("coding", 7); + } else if (event.type === "file_read") { + if (isTestPath(path)) add("testing", 3); + else if (isDocPath(path)) add("docs", 3); + else if (isConfigPath(path)) add("config", 3); + else add("exploration", 4); + } + + // ---- Tool signals ---- + if (tool === "edit" || tool === "multiedit" || tool === "write") { + if (isTestPath(path)) add("testing", 4); + else if (isDocPath(path)) add("docs", 4); + else add("coding", 4); + } + if (tool === "read") add("exploration", 1); + if (tool === "grep" || tool === "glob") add("exploration", 3); + if (tool === "webfetch" || tool === "websearch") add("research", 6); + if (tool === "task") add("planning", 2); + + // ---- Shell-command signals ---- + if (event.type === "shell_exec" || tool === "bash") { + if (/(\bnpm test\b|\bvitest\b|\bjest\b|\bpytest\b|\bmocha\b|\bpnpm test\b|\bcargo test\b|\bgo test\b)/.test(cmd)) { + add("testing", 8); + } + if (/(\bdocker\b|\bkubectl\b|\bterraform\b|\bansible\b|\bhelm\b|\baws\b|\bgcloud\b|\bsystemctl\b)/.test(cmd)) { + add("devops", 7); + } + if (/\bgit\s+(diff|status|log|blame|show)/.test(cmd)) add("review", 4); + if (/\bgit\s+(add|commit|push|merge|rebase|checkout)/.test(cmd)) add("coding", 3); + if (/\b(eslint|prettier|tsc|typecheck|lint|mypy|pyright)\b/.test(cmd)) add("review", 3); + if (/\b(make|cargo|npm run|pnpm run|yarn run|bun run)\b/.test(cmd)) add("coding", 2); + if (toolError) add("debugging", 4); + } + + // ---- Text signals (prompt + response + thinking content) ---- + if (event.type === "prompt" || event.type === "response") { + if (/\b(refactor|rename|extract|inline|move|reorganize|restructure)\b/.test(text)) { + add("refactor", 6); + } + if (/\b(error|exception|stack[- ]?trace|traceback|fail(?:ed|ing|ure)?|broken|bug|crash|throws?|undefined is not|cannot read prop|nullpointer)\b/.test(text)) { + add("debugging", 5); + } + if (/\b(test(?:s|ing)?|assert(?:ion)?s?|spec(?:s|tests)?|coverage|mock(?:s|ing)?)\b/.test(text)) { + add("testing", 3); + } + if (/\b(review|audit|check|look at|inspect|verify|critique)\b/.test(text)) { + add("review", 4); + } + if (/\b(plan|approach|step\s\d|first[, ]|then\b|finally\b|let\s+me\s+think|let\s+us|design)\b/.test(text)) { + add("planning", 2); + } + if (/\b(deploy|deployment|release|rollout|pipeline|ci\/cd|production|staging|prod\b)\b/.test(text)) { + add("devops", 3); + } + if (/\b(document(?:ation)?|readme|changelog|docs?\b|comment[s]?|jsdoc|tsdoc|docstring)\b/.test(text)) { + add("docs", 3); + } + if (/\b(config(?:ure|uration)?|settings|environment|env\s+var|toml|yaml|yml|tsconfig|package\.json)\b/.test(text)) { + add("config", 3); + } + if (/\b(research|read about|articles?|paper(s)?|blog\b|reference|literature)\b/.test(text)) { + add("research", 3); + } + if (/\b(what is|how does|how do i|where is|find\b|search\b|locate\b)\b/.test(text)) { + add("exploration", 2); + } + } + + // ---- Thinking-block weight: long thinking dominates planning ---- + const thinkingLen = thinking.length; + if (thinkingLen > 1500) add("planning", 5); + else if (thinkingLen > 300) add("planning", 2); + + // ---- Catch-all so chat wins for empty assistant chatter ---- + if (event.type === "prompt" || event.type === "response") { + if (Object.keys(scores).length === 0) add("chat", 1); + } + + // Session-scaffolding events shouldn't bias category weight; classify + // them as chat by convention so they don't poison the pie chart. + if (event.type === "session_start" || event.type === "session_end") { + return { chat: 1 }; + } + if (event.type === "compaction") { + return { planning: 1 }; + } + if (event.type === "parse_error") { + return { chat: 0 }; + } + + return scores; +} + +function isTestPath(p: string): boolean { + if (!p) return false; + return /(^|\/)(tests?|__tests__|spec)\//.test(p) || /\.(test|spec)\.[a-z0-9]+$/.test(p); +} + +function isDocPath(p: string): boolean { + if (!p) return false; + if (/\.(md|mdx|rst|adoc|txt)$/.test(p)) return true; + if (/(^|\/)(docs?|guides?|examples?)\//.test(p)) return true; + if (/(^|\/)(readme|changelog|contributing|license|security|code_of_conduct)/i.test(p)) { + return true; + } + return false; +} + +function isConfigPath(p: string): boolean { + if (!p) return false; + if (/\.(json|ya?ml|toml|ini|env|config\.[jt]s|cjs|mjs)$/.test(p)) return true; + if (/(^|\/)(\.env(?:\..*)?|tsconfig|tsup\.config|vitest\.config|vite\.config|jest\.config|babel\.config|webpack\.config|rollup\.config|prettier\.config|eslint\.config|tailwind\.config|postcss\.config)$/i.test(p)) { + return true; + } + if (/(^|\/)package\.json$/i.test(p)) return true; + return false; +} diff --git a/src/classify/index.ts b/src/classify/index.ts new file mode 100644 index 0000000..b141b53 --- /dev/null +++ b/src/classify/index.ts @@ -0,0 +1,7 @@ +export { + classifyEvent, + scoreEvent, + ACTIVITY_CATEGORIES, + type ActivityCategory, +} from "./activity.js"; +export { withClassifier } from "./sink.js"; diff --git a/src/classify/sink.ts b/src/classify/sink.ts new file mode 100644 index 0000000..b9df9b5 --- /dev/null +++ b/src/classify/sink.ts @@ -0,0 +1,24 @@ +import type { AgentEvent, EventDetails, EventSink } from "../schema.js"; +import { classifyEvent } from "./activity.js"; + +/** Wraps an EventSink so every emitted event has `details.category` + * attached before it propagates further. Idempotent — if a category + * is already present we leave it (some upstream might have set a + * better one). + * + * Place this BEFORE the store wrapper so the categorization is + * persisted alongside the event. */ +export function withClassifier(inner: EventSink): EventSink { + return { + emit: (event: AgentEvent) => { + if (!event.details) event.details = {}; + if (!event.details.category) { + event.details.category = classifyEvent(event); + } + inner.emit(event); + }, + enrich: (eventId: string, patch: Partial<EventDetails>) => { + inner.enrich(eventId, patch); + }, + }; +} diff --git a/src/daemon/daemon.test.ts b/src/daemon/daemon.test.ts new file mode 100644 index 0000000..f08415f --- /dev/null +++ b/src/daemon/daemon.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DAEMON_LABEL, + renderPlist, + renderSystemdUnit, + resolveAgentwatchExec, +} from "./install.js"; +import { RotatingLogStream } from "./log-rotate.js"; +import { isProcessAlive } from "./run.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "agentwatch-daemon-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("daemon — log rotation", () => { + it("appends to a fresh log file", () => { + const path = join(dir, "log"); + const log = new RotatingLogStream({ path, maxBytes: 1024 }); + log.write("hello"); + log.write("world"); + log.close(); + const content = readFileSync(path, "utf-8"); + expect(content).toBe("hello\nworld\n"); + }); + + it("rotates when the next write would exceed maxBytes", () => { + const path = join(dir, "log"); + const log = new RotatingLogStream({ path, maxBytes: 50 }); + log.write("x".repeat(40)); // 41 bytes including trailing newline + log.write("y".repeat(40)); // would push us past 50 → rotate first + log.close(); + expect(existsSync(`${path}.1`)).toBe(true); + const main = readFileSync(path, "utf-8"); + const rotated = readFileSync(`${path}.1`, "utf-8"); + expect(rotated).toContain("x".repeat(40)); + expect(main).toContain("y".repeat(40)); + expect(main.length).toBeLessThan(50); + }); + + it("creates the parent directory if missing", () => { + const nested = join(dir, "nested", "deeper", "log"); + const log = new RotatingLogStream({ path: nested }); + log.write("ok"); + log.close(); + expect(existsSync(nested)).toBe(true); + }); + + it("survives a re-open by appending, not truncating", () => { + const path = join(dir, "log"); + let log = new RotatingLogStream({ path }); + log.write("first"); + log.close(); + log = new RotatingLogStream({ path }); + log.write("second"); + log.close(); + const content = readFileSync(path, "utf-8"); + expect(content).toContain("first"); + expect(content).toContain("second"); + expect(content.indexOf("first")).toBeLessThan(content.indexOf("second")); + }); +}); + +describe("daemon — service unit rendering", () => { + it("renders a launchd plist with the daemon label and four-arg ProgramArguments", () => { + const plist = renderPlist( + { node: "/usr/local/bin/node", script: "/opt/agentwatch/bin/agentwatch.js" }, + "/Users/x/.agentwatch/daemon.log", + ); + expect(plist).toContain(`<string>${DAEMON_LABEL}</string>`); + expect(plist).toContain("<string>/usr/local/bin/node</string>"); + expect(plist).toContain( + "<string>/opt/agentwatch/bin/agentwatch.js</string>", + ); + expect(plist).toContain("<string>daemon</string>"); + expect(plist).toContain("<string>run</string>"); + expect(plist).toContain("<key>RunAtLoad</key>"); + expect(plist).toContain("<key>KeepAlive</key>"); + expect(plist).toContain( + "<string>/Users/x/.agentwatch/daemon.log</string>", + ); + }); + + it("renders a systemd unit with simple type, restart-on-failure, and log redirect", () => { + const unit = renderSystemdUnit( + { node: "/usr/bin/node", script: "/opt/agentwatch/bin/agentwatch.js" }, + "/home/x/.agentwatch/daemon.log", + ); + expect(unit).toContain("Description=agentwatch event capture daemon"); + expect(unit).toContain("Type=simple"); + expect(unit).toContain( + "ExecStart=/usr/bin/node /opt/agentwatch/bin/agentwatch.js daemon run", + ); + expect(unit).toContain("Restart=on-failure"); + expect(unit).toContain("WantedBy=default.target"); + expect(unit).toContain( + "StandardOutput=append:/home/x/.agentwatch/daemon.log", + ); + }); + + it("resolveAgentwatchExec uses process.execPath + argv[1]", () => { + const exec = resolveAgentwatchExec(); + expect(exec.node).toBe(process.execPath); + expect(exec.script.length).toBeGreaterThan(0); + }); +}); + +describe("daemon — process liveness probe", () => { + it("reports the current process as alive", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + it("reports a definitely-dead pid as not alive", () => { + // PID 0 is reserved (kernel scheduler) — process.kill rejects it. + // Use a high pid that's almost certainly unused. + expect(isProcessAlive(2_000_000_000)).toBe(false); + }); +}); + +describe("daemon — log file size budget", () => { + it("never grows the active log past maxBytes by more than one line", () => { + const path = join(dir, "log"); + const log = new RotatingLogStream({ path, maxBytes: 200 }); + for (let i = 0; i < 50; i++) log.write(`line ${i} `.repeat(5)); + log.close(); + const size = statSync(path).size; + // After the last rotation, the active file holds only writes that + // came post-rotation. Bound is one full line over the cap. + expect(size).toBeLessThan(400); + }); +}); diff --git a/src/daemon/index.ts b/src/daemon/index.ts new file mode 100644 index 0000000..bb0c262 --- /dev/null +++ b/src/daemon/index.ts @@ -0,0 +1,226 @@ +import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; +import { + DAEMON_LABEL, + logPath, + pidFilePath, + plistPath, + removeServiceUnit, + startTimeFilePath, + systemdUnitPath, + writeServiceUnit, +} from "./install.js"; +import { isProcessAlive, runDaemon } from "./run.js"; + +const HELP = `agentwatch daemon — background event capture + +Usage: + agentwatch daemon start install + load the user-level service + agentwatch daemon stop unload the service (events.db is preserved) + agentwatch daemon status running state + uptime + capture stats + agentwatch daemon logs tail the daemon log + agentwatch daemon run foreground mode (used by launchd / systemd) + +Service files: + macOS: ~/Library/LaunchAgents/${DAEMON_LABEL}.plist + Linux: ~/.config/systemd/user/agentwatch.service + +The daemon writes every adapter event to ~/.agentwatch/events.db. The +TUI and \`agentwatch serve\` read the same store, so events captured +overnight are visible the moment you open them. +`; + +export async function dispatchDaemon(sub: string | undefined): Promise<void> { + switch (sub) { + case undefined: + case "--help": + case "-h": + console.log(HELP); + process.exit(0); + return; + case "start": + return startCmd(); + case "stop": + return stopCmd(); + case "status": + return statusCmd(); + case "logs": + return logsCmd(); + case "run": + await runDaemon(); + return; + default: + process.stderr.write(`agentwatch daemon: unknown subcommand "${sub}"\n`); + process.stderr.write(HELP); + process.exit(2); + } +} + +function startCmd(): void { + const result = writeServiceUnit(); + console.log(`wrote service unit: ${result.unitPath}`); + if (platform() === "darwin") { + runStep(["launchctl", "unload", result.unitPath], { allowFail: true }); + runStep(["launchctl", "load", "-w", result.unitPath]); + console.log(`daemon loaded — events stream into ~/.agentwatch/events.db`); + return; + } + if (platform() === "linux") { + runStep(["systemctl", "--user", "daemon-reload"]); + runStep(["systemctl", "--user", "enable", "--now", "agentwatch.service"]); + console.log(`daemon enabled + started`); + return; + } + console.log(`unsupported platform; manual steps:`); + for (const cmd of result.manualSteps) console.log(` ${cmd}`); +} + +function stopCmd(): void { + if (platform() === "darwin") { + const path = plistPath(); + if (existsSync(path)) { + runStep(["launchctl", "unload", path], { allowFail: true }); + } + const removed = removeServiceUnit(); + console.log(`daemon stopped${removed.unitPath ? ` (removed ${removed.unitPath})` : ""}`); + return; + } + if (platform() === "linux") { + runStep(["systemctl", "--user", "disable", "--now", "agentwatch.service"], { + allowFail: true, + }); + runStep(["systemctl", "--user", "daemon-reload"], { allowFail: true }); + const removed = removeServiceUnit(); + console.log(`daemon stopped${removed.unitPath ? ` (removed ${removed.unitPath})` : ""}`); + return; + } + console.log(`unsupported platform — kill the process manually`); +} + +function statusCmd(): void { + const status = readDaemonStatus(); + if (!status.running) { + console.log(`daemon: not running`); + if (status.unitInstalled) console.log(`unit installed at: ${status.unitPath}`); + process.exit(0); + } + console.log(`daemon: running (pid ${status.pid})`); + console.log(`uptime: ${formatUptime(status.uptimeMs)}`); + console.log( + `events captured: ${status.eventsCaptured}` + + (status.lastEventTs ? ` · last at ${status.lastEventTs}` : ""), + ); + if (status.unitPath) console.log(`unit: ${status.unitPath}`); + if (status.dbBytes != null) { + console.log(`db size: ${(status.dbBytes / 1_048_576).toFixed(1)} MB`); + } +} + +export interface DaemonStatus { + running: boolean; + pid?: number; + uptimeMs: number; + eventsCaptured: number; + lastEventTs?: string; + unitInstalled: boolean; + unitPath?: string; + dbBytes?: number; +} + +export function readDaemonStatus(): DaemonStatus { + const pidFile = pidFilePath(); + const unitPath = + platform() === "darwin" + ? plistPath() + : platform() === "linux" + ? systemdUnitPath() + : undefined; + const unitInstalled = unitPath ? existsSync(unitPath) : false; + + let pid: number | undefined; + let uptimeMs = 0; + if (existsSync(pidFile)) { + const raw = readFileSync(pidFile, "utf-8").trim(); + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0 && isProcessAlive(parsed)) { + pid = parsed; + } + } + if (pid && existsSync(startTimeFilePath())) { + const startMs = Number(readFileSync(startTimeFilePath(), "utf-8").trim()); + if (Number.isFinite(startMs)) uptimeMs = Math.max(0, Date.now() - startMs); + } + + let eventsCaptured = 0; + let lastEventTs: string | undefined; + let dbBytes: number | undefined; + try { + // Lazy require so a missing better-sqlite3 (rare) doesn't break status. + const { openStore } = require("../store/sqlite.js") as typeof import("../store/sqlite.js"); + const store = openStore(); + const stats = store.stats(); + eventsCaptured = stats.events; + dbBytes = stats.dbBytes; + const sessions = store.listSessions({ limit: 1 }); + lastEventTs = sessions[0]?.lastTs; + store.close(); + } catch { + // store unreachable; report what we know + } + + return { + running: pid != null, + ...(pid != null ? { pid } : {}), + uptimeMs, + eventsCaptured, + ...(lastEventTs ? { lastEventTs } : {}), + unitInstalled, + ...(unitPath ? { unitPath } : {}), + ...(dbBytes != null ? { dbBytes } : {}), + }; +} + +function logsCmd(): void { + const path = logPath(); + if (!existsSync(path)) { + console.log(`(no log yet at ${path})`); + return; + } + // Use tail -f if available; fall back to printing the file. + const tail = spawnSync("tail", ["-n", "200", "-f", path], { + stdio: "inherit", + }); + if (tail.error) { + process.stdout.write(readFileSync(path, "utf-8")); + } +} + +function runStep( + argv: string[], + opts: { allowFail?: boolean } = {}, +): void { + const [cmd, ...rest] = argv; + if (!cmd) return; + const result = spawnSync(cmd, rest, { stdio: "inherit" }); + if (result.error) { + if (opts.allowFail) return; + throw new Error(`spawn ${cmd}: ${String(result.error)}`); + } + if (result.status !== 0 && !opts.allowFail) { + throw new Error(`${argv.join(" ")} exited ${result.status}`); + } +} + +function formatUptime(ms: number): string { + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`; + if (ms < 86_400_000) { + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + return `${h}h ${m}m`; + } + const d = Math.floor(ms / 86_400_000); + const h = Math.floor((ms % 86_400_000) / 3_600_000); + return `${d}d ${h}h`; +} diff --git a/src/daemon/install.ts b/src/daemon/install.ts new file mode 100644 index 0000000..cfcfd1c --- /dev/null +++ b/src/daemon/install.ts @@ -0,0 +1,140 @@ +import { homedir, platform } from "node:os"; +import { join, resolve } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; + +export const DAEMON_LABEL = "com.agentwatch.daemon"; + +export interface DaemonExec { + /** Absolute path to the node binary that should run the daemon. */ + node: string; + /** Absolute path to the agentwatch entry script (bin/agentwatch.js or src/index.tsx). */ + script: string; +} + +export function resolveAgentwatchExec(): DaemonExec { + return { + node: process.execPath, + script: resolve(process.argv[1] ?? ""), + }; +} + +export function plistPath(): string { + return join(homedir(), "Library", "LaunchAgents", `${DAEMON_LABEL}.plist`); +} + +export function systemdUnitPath(): string { + return join(homedir(), ".config", "systemd", "user", "agentwatch.service"); +} + +export function logPath(): string { + return join(homedir(), ".agentwatch", "daemon.log"); +} + +export function pidFilePath(): string { + return join(homedir(), ".agentwatch", "daemon.pid"); +} + +export function startTimeFilePath(): string { + return join(homedir(), ".agentwatch", "daemon.started_at"); +} + +export function renderPlist(exec: DaemonExec, log: string): string { + return `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>${DAEMON_LABEL}</string> + <key>ProgramArguments</key> + <array> + <string>${exec.node}</string> + <string>${exec.script}</string> + <string>daemon</string> + <string>run</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>ProcessType</key> + <string>Background</string> + <key>StandardOutPath</key> + <string>${log}</string> + <key>StandardErrorPath</key> + <string>${log}</string> +</dict> +</plist> +`; +} + +export function renderSystemdUnit(exec: DaemonExec, log: string): string { + return `[Unit] +Description=agentwatch event capture daemon +After=network-online.target + +[Service] +Type=simple +ExecStart=${exec.node} ${exec.script} daemon run +Restart=on-failure +RestartSec=5 +StandardOutput=append:${log} +StandardError=append:${log} + +[Install] +WantedBy=default.target +`; +} + +export interface InstallResult { + unitPath: string; + /** Shell commands the operator should run if our spawn() failed (e.g. + * no launchctl on PATH). Empty when the install fully succeeded. */ + manualSteps: string[]; +} + +export function writeServiceUnit(): InstallResult { + const exec = resolveAgentwatchExec(); + const log = logPath(); + mkdirSync(join(homedir(), ".agentwatch"), { recursive: true }); + if (platform() === "darwin") { + const path = plistPath(); + mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); + writeFileSync(path, renderPlist(exec, log), "utf-8"); + return { + unitPath: path, + manualSteps: [`launchctl load -w ${path}`], + }; + } + if (platform() === "linux") { + const path = systemdUnitPath(); + mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true }); + writeFileSync(path, renderSystemdUnit(exec, log), "utf-8"); + return { + unitPath: path, + manualSteps: [ + "systemctl --user daemon-reload", + "systemctl --user enable --now agentwatch.service", + ], + }; + } + throw new Error( + `agentwatch daemon: unsupported platform "${platform()}" (Windows is on the v0.2 roadmap)`, + ); +} + +export function removeServiceUnit(): { unitPath: string | null } { + const path = platform() === "darwin" + ? plistPath() + : platform() === "linux" + ? systemdUnitPath() + : null; + if (!path) return { unitPath: null }; + if (existsSync(path)) { + try { + unlinkSync(path); + } catch { + // best effort + } + } + return { unitPath: path }; +} diff --git a/src/daemon/log-rotate.ts b/src/daemon/log-rotate.ts new file mode 100644 index 0000000..9c28079 --- /dev/null +++ b/src/daemon/log-rotate.ts @@ -0,0 +1,73 @@ +import { closeSync, openSync, renameSync, statSync, writeSync } from "node:fs"; +import { dirname } from "node:path"; +import { mkdirSync } from "node:fs"; + +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; + +/** Append-only log writer with a single rotation slot. + * + * When the current log size exceeds `maxBytes`, the file is renamed to + * `<path>.1` (overwriting any previous one) and a fresh `<path>` is + * opened. The daemon never holds a long-running write stream — every + * `write` is `writeSync` so we don't have to drain on shutdown. + * + * Single rotation slot is intentional: ten megabytes of history is the + * upper bound, plus a recent ten in `.1`. Anything older isn't worth + * the disk. Operators who want longer history pipe the log to a + * separate journal. */ +export class RotatingLogStream { + private fd: number; + private bytes: number; + private readonly path: string; + private readonly maxBytes: number; + + constructor(opts: { path: string; maxBytes?: number }) { + this.path = opts.path; + this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + mkdirSync(dirname(this.path), { recursive: true }); + this.fd = openSync(this.path, "a"); + try { + this.bytes = statSync(this.path).size; + } catch { + this.bytes = 0; + } + } + + write(line: string): void { + const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`); + if (this.bytes + buf.length > this.maxBytes) { + this.rotate(); + } + writeSync(this.fd, buf); + this.bytes += buf.length; + } + + /** Test seam — read current bytes-on-disk for assertions. */ + byteCount(): number { + return this.bytes; + } + + close(): void { + try { + closeSync(this.fd); + } catch { + // already closed + } + } + + private rotate(): void { + try { + closeSync(this.fd); + } catch { + // already closed + } + try { + renameSync(this.path, `${this.path}.1`); + } catch { + // best effort — if rename fails (e.g. read-only fs) just keep + // appending and let an operator handle it. + } + this.fd = openSync(this.path, "a"); + this.bytes = 0; + } +} diff --git a/src/daemon/run.ts b/src/daemon/run.ts new file mode 100644 index 0000000..176317b --- /dev/null +++ b/src/daemon/run.ts @@ -0,0 +1,192 @@ +import { + existsSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { logPath, pidFilePath, startTimeFilePath } from "./install.js"; +import { RotatingLogStream } from "./log-rotate.js"; +import type { AgentEvent, EventDetails, EventSink } from "../schema.js"; +import { clampTs } from "../schema.js"; + +/** Run the daemon as a foreground process. The launchd plist / systemd + * unit invokes `agentwatch daemon run` and treats this as the long- + * running supervised process; KeepAlive / Restart handles crash loops. + * + * Responsibilities here: + * 1. Open the SQLite store + * 2. Start every adapter wired through wrapSinkWithStore so events + * persist on disk + * 3. Write our PID + start time so `daemon status` can read them + * 4. Drain on SIGTERM / SIGINT — close adapters, close store + * 5. Stay alive until signaled + * + * No TUI, no web server, no notifications. The TUI and `agentwatch + * serve` are clients of the same SQLite store; they observe daemon- + * written events transparently. */ +export async function runDaemon(): Promise<void> { + const dataDir = join(homedir(), ".agentwatch"); + mkdirSync(dataDir, { recursive: true }); + + const lock = acquireLock(); + if (!lock.ok) { + process.stderr.write( + `[agentwatch daemon] another instance is already running (pid ${lock.existingPid}). Exiting.\n`, + ); + process.exit(2); + } + + const log = new RotatingLogStream({ path: logPath() }); + const logLine = (msg: string): void => { + log.write(`${new Date().toISOString()} ${msg}`); + }; + logLine(`daemon starting (pid ${process.pid})`); + + let store: import("../store/sqlite.js").EventStore | null = null; + let stoppingHooks: Array<() => Promise<void> | void> = []; + + try { + const { openStore, wrapSinkWithStore } = await import("../store/index.js"); + const { startAllAdapters, stopAllAdapters } = await import( + "../adapters/registry.js" + ); + const { detectWorkspaceRoot } = await import("../util/workspace.js"); + + store = openStore(); + const workspace = detectWorkspaceRoot(); + + let captured = 0; + const inner: EventSink = { + emit: (e: AgentEvent) => { + e.ts = clampTs(e.ts); + captured += 1; + }, + enrich: (_id: string, _patch: Partial<EventDetails>) => { + // Daemon-side enrich is purely a store update — handled by the + // wrapper; nothing additional to do here. + }, + }; + const sink = wrapSinkWithStore(inner, store); + const adapters = startAllAdapters(sink, workspace); + stoppingHooks.push(() => stopAllAdapters(adapters)); + stoppingHooks.push(() => store?.close()); + + logLine(`adapters started; workspace=${workspace}`); + + // Periodic heartbeat to the log so operators can confirm the daemon + // is healthy without parsing PID-state. Once a minute is enough. + const heartbeat = setInterval(() => { + logLine(`heartbeat captured=${captured}`); + }, 60_000); + heartbeat.unref(); + stoppingHooks.push(() => clearInterval(heartbeat)); + + setupShutdown(stoppingHooks, log, lock.releaseLock); + + // Block forever — adapters keep the event loop alive via their + // chokidar watchers. We add a never-resolving promise as a belt and + // suspenders so an adapter shutting down cleanly doesn't drop us. + await new Promise<void>(() => undefined); + } catch (err) { + logLine(`fatal: ${String(err)}`); + for (const hook of stoppingHooks) { + try { + await hook(); + } catch { + // best effort + } + } + lock.releaseLock(); + log.close(); + process.exit(1); + } +} + +interface LockHandle { + ok: boolean; + existingPid?: number; + releaseLock: () => void; +} + +function acquireLock(): LockHandle { + const pidFile = pidFilePath(); + const startFile = startTimeFilePath(); + if (existsSync(pidFile)) { + const raw = readFileSync(pidFile, "utf-8").trim(); + const pid = Number(raw); + if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) { + return { ok: false, existingPid: pid, releaseLock: () => undefined }; + } + // Stale PID file — remove it and continue. + try { + unlinkSync(pidFile); + } catch { + // best effort + } + } + mkdirSync(dirname(pidFile), { recursive: true }); + writeFileSync(pidFile, String(process.pid), "utf-8"); + writeFileSync(startFile, String(Date.now()), "utf-8"); + return { + ok: true, + releaseLock: () => { + try { + unlinkSync(pidFile); + } catch { + // best effort + } + try { + unlinkSync(startFile); + } catch { + // best effort + } + }, + }; +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // EPERM means the process exists but we can't signal it — still alive. + if (code === "EPERM") return true; + return false; + } +} + +function setupShutdown( + hooks: Array<() => Promise<void> | void>, + log: RotatingLogStream, + releaseLock: () => void, +): void { + let shutting = false; + const stop = async (sig: string): Promise<void> => { + if (shutting) return; + shutting = true; + log.write( + `${new Date().toISOString()} shutdown signal=${sig} draining ${hooks.length} hooks\n`, + ); + for (const hook of hooks) { + try { + await hook(); + } catch (err) { + log.write( + `${new Date().toISOString()} shutdown hook error: ${String(err)}\n`, + ); + } + } + releaseLock(); + log.write(`${new Date().toISOString()} daemon stopped cleanly\n`); + log.close(); + process.exit(0); + }; + process.on("SIGTERM", () => void stop("SIGTERM")); + process.on("SIGINT", () => void stop("SIGINT")); + process.on("SIGHUP", () => void stop("SIGHUP")); +} diff --git a/src/git/correlate.test.ts b/src/git/correlate.test.ts new file mode 100644 index 0000000..aa86211 --- /dev/null +++ b/src/git/correlate.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + aggregateProjectYield, + correlateSessionYield, + findProjectGitRoot, + listCommits, + mondayOfWeekIso, + type Commit, +} from "./correlate.js"; +import type { SessionSummary } from "../store/sqlite.js"; + +let workspace: string; +let repo: string; + +function gitInit(path: string): void { + execSync(`git init -q ${path}`); + execSync(`git -C ${path} config user.email agent@test`); + execSync(`git -C ${path} config user.name agent`); + execSync(`git -C ${path} config commit.gpgsign false`); +} + +function commit(repoPath: string, file: string, content: string, msg: string, ts: string): void { + writeFileSync(join(repoPath, file), content); + execSync(`git -C ${repoPath} add ${file}`); + execSync( + `GIT_AUTHOR_DATE='${ts}' GIT_COMMITTER_DATE='${ts}' git -C ${repoPath} commit -q -m '${msg.replace(/'/g, "")}' --no-gpg-sign`, + ); +} + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "agentwatch-yield-ws-")); + repo = join(workspace, "demo"); + execSync(`mkdir -p ${repo}`); + gitInit(repo); +}); + +afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); +}); + +describe("git/correlate — findProjectGitRoot", () => { + it("returns the absolute path when the project dir contains a .git folder", () => { + const root = findProjectGitRoot(workspace, "demo"); + expect(root).not.toBeNull(); + expect(root).toMatch(/\/demo$/); + }); + + it("returns null when the directory exists but has no .git", () => { + execSync(`mkdir -p ${join(workspace, "no-repo")}`); + expect(findProjectGitRoot(workspace, "no-repo")).toBeNull(); + }); + + it("returns null when the project dir doesn't exist", () => { + expect(findProjectGitRoot(workspace, "missing")).toBeNull(); + }); + + it("returns null for a non-existent workspace", () => { + expect(findProjectGitRoot("/does/not/exist", "demo")).toBeNull(); + }); +}); + +describe("git/correlate — listCommits", () => { + it("returns commits with ISO author dates, insertions, and deletions", () => { + commit(repo, "a.txt", "hello\n", "first", "2026-04-10T10:00:00Z"); + commit(repo, "a.txt", "hello\nworld\n", "second", "2026-04-11T10:00:00Z"); + const commits = listCommits(repo); + expect(commits).toHaveLength(2); + expect(commits[0]?.subject).toBe("first"); + expect(commits[0]?.authorDate.startsWith("2026-04-10")).toBe(true); + expect(commits[0]?.insertions).toBeGreaterThanOrEqual(1); + expect(commits[1]?.subject).toBe("second"); + expect(commits[1]?.insertions).toBeGreaterThanOrEqual(1); + }); + + it("filters by --since / --until", () => { + commit(repo, "a.txt", "1\n", "old", "2026-03-01T00:00:00Z"); + commit(repo, "a.txt", "2\n", "new", "2026-04-15T00:00:00Z"); + const commits = listCommits(repo, { since: "2026-04-01T00:00:00Z" }); + expect(commits).toHaveLength(1); + expect(commits[0]?.subject).toBe("new"); + }); + + it("returns [] for a non-git directory", () => { + expect(listCommits(workspace)).toEqual([]); + }); +}); + +describe("git/correlate — correlateSessionYield", () => { + function session(over: Partial<SessionSummary>): SessionSummary { + return { + sessionId: over.sessionId ?? "s", + agent: over.agent ?? "claude-code", + project: over.project ?? "demo", + firstTs: over.firstTs ?? "2026-04-10T10:00:00Z", + lastTs: over.lastTs ?? "2026-04-10T11:00:00Z", + eventCount: over.eventCount ?? 1, + costUsd: over.costUsd ?? 1.0, + }; + } + + function commitFixture(over: Partial<Commit>): Commit { + return { + hash: over.hash ?? "h", + authorDate: over.authorDate ?? "2026-04-10T10:30:00Z", + authorName: over.authorName ?? "agent", + filesChanged: over.filesChanged ?? 1, + insertions: over.insertions ?? 5, + deletions: over.deletions ?? 2, + subject: over.subject ?? "msg", + }; + } + + it("matches commits inside the session window + 30 min grace", () => { + const inWindow = commitFixture({ hash: "in", authorDate: "2026-04-10T11:20:00Z" }); + const tooLate = commitFixture({ hash: "late", authorDate: "2026-04-10T12:00:00Z" }); + const tooEarly = commitFixture({ hash: "early", authorDate: "2026-04-10T09:30:00Z" }); + const y = correlateSessionYield(session({}), [inWindow, tooLate, tooEarly]); + expect(y.commits.map((c) => c.hash)).toEqual(["in"]); + }); + + it("computes cost-per-commit + cost-per-line", () => { + const c1 = commitFixture({ hash: "a", insertions: 4, deletions: 1 }); // 5 lines + const c2 = commitFixture({ hash: "b", insertions: 3, deletions: 2 }); // 5 lines + const y = correlateSessionYield(session({ costUsd: 2.0 }), [c1, c2]); + expect(y.costPerCommit).toBeCloseTo(1.0); + expect(y.costPerLineChanged).toBeCloseTo(2.0 / 10); + expect(y.totalInsertions).toBe(7); + expect(y.totalDeletions).toBe(3); + expect(y.totalFilesChanged).toBe(2); + }); + + it("returns null cost-per-* when no commits match", () => { + const y = correlateSessionYield(session({}), []); + expect(y.commits).toEqual([]); + expect(y.costPerCommit).toBeNull(); + expect(y.costPerLineChanged).toBeNull(); + }); +}); + +describe("git/correlate — aggregateProjectYield", () => { + it("buckets cost + commits per ISO week and surfaces spend-without-commit", () => { + const sessions: SessionSummary[] = [ + { + sessionId: "s1", + agent: "claude-code", + project: "demo", + firstTs: "2026-04-06T10:00:00Z", // Monday → 2026-04-06 + lastTs: "2026-04-06T11:00:00Z", + eventCount: 5, + costUsd: 2.0, + }, + { + sessionId: "s2", + agent: "claude-code", + project: "demo", + firstTs: "2026-04-13T10:00:00Z", // Monday → 2026-04-13 + lastTs: "2026-04-13T11:00:00Z", + eventCount: 5, + costUsd: 1.0, + }, + { + sessionId: "s3", + agent: "claude-code", + project: "demo", + firstTs: "2026-04-20T10:00:00Z", + lastTs: "2026-04-20T11:00:00Z", + eventCount: 5, + costUsd: 0.5, // no commits this week + }, + ]; + const commits: Commit[] = [ + { + hash: "c1", + authorDate: "2026-04-06T10:30:00Z", + authorName: "x", + filesChanged: 1, + insertions: 5, + deletions: 0, + subject: "x", + }, + { + hash: "c2", + authorDate: "2026-04-13T10:30:00Z", + authorName: "x", + filesChanged: 1, + insertions: 1, + deletions: 0, + subject: "y", + }, + ]; + const yld = aggregateProjectYield("demo", sessions, commits); + expect(yld.weekly.length).toBe(3); + const wk1 = yld.weekly.find((w) => w.weekStart === "2026-04-06"); + expect(wk1?.commits).toBe(1); + expect(wk1?.costPerCommit).toBeCloseTo(2.0); + const wk3 = yld.weekly.find((w) => w.weekStart === "2026-04-20"); + expect(wk3?.commits).toBe(0); + expect(wk3?.costPerCommit).toBeNull(); + expect(yld.spendWithoutCommit).toHaveLength(1); + expect(yld.spendWithoutCommit[0]?.sessionId).toBe("s3"); + }); +}); + +describe("git/correlate — mondayOfWeekIso", () => { + it("snaps a Wednesday to the preceding Monday", () => { + expect(mondayOfWeekIso("2026-04-08T15:00:00Z")).toBe("2026-04-06"); + }); + + it("returns the same Monday when the input is already Monday", () => { + expect(mondayOfWeekIso("2026-04-06T00:00:00Z")).toBe("2026-04-06"); + }); + + it("snaps a Sunday back to the preceding Monday (ISO week)", () => { + expect(mondayOfWeekIso("2026-04-12T23:00:00Z")).toBe("2026-04-06"); + }); +}); diff --git a/src/git/correlate.ts b/src/git/correlate.ts new file mode 100644 index 0000000..21a9cbb --- /dev/null +++ b/src/git/correlate.ts @@ -0,0 +1,271 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { SessionSummary } from "../store/sqlite.js"; + +export interface Commit { + hash: string; + authorDate: string; // ISO + authorName: string; + filesChanged: number; + insertions: number; + deletions: number; + subject: string; +} + +export interface SessionYield { + sessionId: string; + costUsd: number; + commits: Commit[]; + totalInsertions: number; + totalDeletions: number; + totalFilesChanged: number; + costPerCommit: number | null; + costPerLineChanged: number | null; +} + +export interface ProjectYieldRow { + weekStart: string; // ISO Monday of the bucket + costUsd: number; + commits: number; + costPerCommit: number | null; +} + +export interface ProjectYield { + project: string; + weekly: ProjectYieldRow[]; + spendWithoutCommit: SessionYield[]; +} + +/** Window the session is allowed to claim commits in: [first_ts, + * last_ts + COMMIT_GRACE_MS]. The grace period accommodates the + * natural gap between the agent finishing edits and the human / agent + * running `git commit`. */ +const COMMIT_GRACE_MS = 30 * 60 * 1000; + +/** Read-only — never invoke mutating git verbs. The worst this can do + * is fail to start (no git on PATH) or time out. */ +const READ_ONLY_GIT_VERBS = new Set([ + "log", + "rev-parse", + "worktree", + "config", + "branch", + "show", + "blame", + "diff", + "status", + "remote", +]); + +function runGit(args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): string { + const verb = args[0]; + if (!verb || !READ_ONLY_GIT_VERBS.has(verb)) { + // Defensive — refuse to spawn git with a verb that could mutate + // state. This module is read-only by contract. + throw new Error(`git verb "${verb}" not in read-only allow-list`); + } + const result = spawnSync("git", args, { + cwd: opts.cwd, + encoding: "utf-8", + timeout: opts.timeoutMs ?? 10_000, + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `git ${args.join(" ")} exited ${result.status}: ${result.stderr.slice(0, 500)}`, + ); + } + return result.stdout; +} + +/** Discover the canonical git root for a `[name]` project tag. + * + * Rule: walk one level under `workspaceRoot` looking for a directory + * whose basename matches `projectName` and which contains a `.git` + * entry (directory for a normal repo, file for a worktree). For + * worktrees we resolve via `git rev-parse --git-common-dir` so two + * paths sharing the same backing repo are treated as one project. */ +export function findProjectGitRoot( + workspaceRoot: string, + projectName: string, +): string | null { + if (!existsSync(workspaceRoot)) return null; + let entries: string[]; + try { + entries = readdirSync(workspaceRoot); + } catch { + return null; + } + for (const entry of entries) { + if (entry !== projectName) continue; + const candidate = join(workspaceRoot, entry); + try { + const s = statSync(candidate); + if (!s.isDirectory()) continue; + } catch { + continue; + } + const gitEntry = join(candidate, ".git"); + if (!existsSync(gitEntry)) continue; + return resolve(candidate); + } + return null; +} + +/** Get the canonical common-dir for a worktree so two checkouts of + * the same repo aren't double-counted. Returns `null` for the input + * path itself if `git rev-parse` fails. */ +export function gitCommonDir(repoPath: string): string | null { + try { + const out = runGit(["rev-parse", "--git-common-dir"], { cwd: repoPath }); + const trimmed = out.trim(); + if (!trimmed) return null; + // git rev-parse returns a relative path on some systems; normalize. + return resolve(repoPath, trimmed); + } catch { + return null; + } +} + +/** List commits in `[since, until]` (both ISO). Returns oldest-first. + * Skips merge commits (--no-merges) so the cost-per-commit metric + * isn't diluted by routine integration commits. */ +export function listCommits( + repoPath: string, + opts: { since?: string; until?: string } = {}, +): Commit[] { + const args = ["log", "--no-merges", "--reverse"]; + if (opts.since) args.push(`--since=${opts.since}`); + if (opts.until) args.push(`--until=${opts.until}`); + // Format: STX prefix on every commit header so the parser can split + // records cleanly even though --numstat injects extra lines between + // commits. Field separator is unit-separator (\x1f) — robust against + // tabs and newlines in subjects. + args.push("--pretty=format:%x02%H%x1f%aI%x1f%an%x1f%s", "--numstat"); + let out: string; + try { + out = runGit(args, { cwd: repoPath }); + } catch { + return []; + } + const records = out.split("\x02").map((r) => r.trim()).filter(Boolean); + const commits: Commit[] = []; + for (const rec of records) { + const headerEnd = rec.indexOf("\n"); + const header = headerEnd === -1 ? rec : rec.slice(0, headerEnd); + const numstat = headerEnd === -1 ? "" : rec.slice(headerEnd + 1); + const [hash, authorDate, authorName, subject] = header.split("\x1f"); + if (!hash || !authorDate) continue; + let insertions = 0; + let deletions = 0; + let files = 0; + for (const line of numstat.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split(/\s+/); + const ins = parts[0] === "-" ? 0 : Number(parts[0] ?? "0"); + const del = parts[1] === "-" ? 0 : Number(parts[1] ?? "0"); + if (Number.isFinite(ins)) insertions += ins; + if (Number.isFinite(del)) deletions += del; + files += 1; + } + commits.push({ + hash, + authorDate, + authorName: authorName ?? "", + filesChanged: files, + insertions, + deletions, + subject: subject ?? "", + }); + } + return commits; +} + +/** Pair a session with the commits whose author_date sits inside the + * session window (first_ts → last_ts + 30min grace). Yields the + * cost-per-commit and cost-per-line metrics for the UI. */ +export function correlateSessionYield( + session: SessionSummary, + commits: Commit[], +): SessionYield { + const firstMs = Date.parse(session.firstTs); + const lastMs = Date.parse(session.lastTs); + const upper = Number.isFinite(lastMs) ? lastMs + COMMIT_GRACE_MS : Infinity; + const lower = Number.isFinite(firstMs) ? firstMs : -Infinity; + + const matched = commits.filter((c) => { + const t = Date.parse(c.authorDate); + if (!Number.isFinite(t)) return false; + return t >= lower && t <= upper; + }); + + let totalInsertions = 0; + let totalDeletions = 0; + let totalFiles = 0; + for (const c of matched) { + totalInsertions += c.insertions; + totalDeletions += c.deletions; + totalFiles += c.filesChanged; + } + const totalLines = totalInsertions + totalDeletions; + return { + sessionId: session.sessionId, + costUsd: session.costUsd, + commits: matched, + totalInsertions, + totalDeletions, + totalFilesChanged: totalFiles, + costPerCommit: matched.length > 0 ? session.costUsd / matched.length : null, + costPerLineChanged: totalLines > 0 ? session.costUsd / totalLines : null, + }; +} + +/** Aggregate yields across every session in a project — weekly cost- + * per-commit + a list of "spend without commit" sessions where the + * agent burned dollars but produced no commits. */ +export function aggregateProjectYield( + project: string, + sessions: SessionSummary[], + commits: Commit[], +): ProjectYield { + const yields = sessions.map((s) => correlateSessionYield(s, commits)); + const weekly = new Map<string, { cost: number; commits: Set<string> }>(); + for (const y of yields) { + const session = sessions.find((s) => s.sessionId === y.sessionId); + if (!session) continue; + const week = mondayOfWeekIso(session.firstTs); + let bucket = weekly.get(week); + if (!bucket) { + bucket = { cost: 0, commits: new Set() }; + weekly.set(week, bucket); + } + bucket.cost += session.costUsd; + for (const c of y.commits) bucket.commits.add(c.hash); + } + const weeklyRows: ProjectYieldRow[] = Array.from(weekly.entries()) + .map(([weekStart, b]) => ({ + weekStart, + costUsd: b.cost, + commits: b.commits.size, + costPerCommit: b.commits.size > 0 ? b.cost / b.commits.size : null, + })) + .sort((a, b) => (a.weekStart < b.weekStart ? -1 : 1)); + const spendWithoutCommit = yields + .filter((y) => y.commits.length === 0 && y.costUsd > 0) + .sort((a, b) => b.costUsd - a.costUsd); + return { project, weekly: weeklyRows, spendWithoutCommit }; +} + +export function mondayOfWeekIso(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const day = d.getUTCDay(); // 0..6 (Sun..Sat) + const offsetToMonday = day === 0 ? -6 : 1 - day; + const monday = new Date(d); + monday.setUTCDate(d.getUTCDate() + offsetToMonday); + monday.setUTCHours(0, 0, 0, 0); + return monday.toISOString().slice(0, 10); +} diff --git a/src/index.tsx b/src/index.tsx index 06f7f20..303818f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,15 +1,37 @@ import { render } from "ink"; import { App } from "./ui/App.js"; +import { restoreTerminal } from "./util/terminal.js"; +import { onShutdown, runShutdownHooks } from "./util/shutdown.js"; const arg = process.argv[2]; +/** Enter the terminal's alternate screen buffer so the TUI takes over the + * viewport and the shell's scrollback is preserved on exit. Leaving the + * alt screen (and restoring raw mode) happens in restoreTerminal(). */ +const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H"; + +function enterAltScreen(): void { + if (process.stdout.isTTY) process.stdout.write(ENTER_ALT_SCREEN); +} + if (arg === "--help" || arg === "-h") { console.log(`agentwatch — local observability for AI coding agents Usage: - agentwatch launch the TUI - agentwatch doctor detect installed agents and print readiness - agentwatch --help show this help + agentwatch launch the TUI + web UI (http://127.0.0.1:3456) + agentwatch serve run only the web server (no TUI, for remote boxes) + agentwatch doctor detect installed agents and print readiness + agentwatch mcp run as an MCP server over stdio + agentwatch daemon ... install + manage the background capture service + (subcommands: start | stop | status | logs) + agentwatch hooks ... install / uninstall / status the Claude Code hooks adapter + agentwatch prune drop events older than --older-than-days (default 90) + agentwatch --help show this help + +Flags: + --no-web TUI only, don't start the web server + --port <n> web server port (default 3456) + --host <addr> web server bind address (default 127.0.0.1) Hotkeys inside the TUI: q quit @@ -17,26 +39,243 @@ Hotkeys inside the TUI: f cycle agent filter p pause / resume event stream c clear events + w open web UI in browser Environment: - WORKSPACE_ROOT override the detected workspace root + WORKSPACE_ROOT override the detected workspace root + AGENTWATCH_PORT override the web server port + AGENTWATCH_HOST override the web server bind address `); process.exit(0); } +if (arg === "mcp") { + try { + const { runMcpServer } = await import("./mcp/server.js"); + await runMcpServer(); + await new Promise<void>((resolve) => { + process.stdin.on("end", resolve); + process.stdin.on("close", resolve); + }); + } catch (err) { + process.stderr.write(`[agentwatch] mcp error: ${String(err)}\n`); + process.exit(1); + } + process.exit(0); +} + +if (arg === "daemon") { + const { dispatchDaemon } = await import("./daemon/index.js"); + await dispatchDaemon(process.argv[3]); + // dispatchDaemon either exits or runs forever; if it returns we're done. + process.exit(0); +} + +if (arg === "hooks") { + const sub = process.argv[3]; + const { + installClaudeHooks, + uninstallClaudeHooks, + claudeHooksStatus, + } = await import("./adapters/claude-hooks-install.js"); + if (sub === "install") { + const port = Number(parseFlag("--port") ?? process.env.AGENTWATCH_PORT ?? "3456"); + const result = installClaudeHooks({ port }); + console.log(`installed agentwatch hooks into ${result.settingsPath}`); + console.log(`events: ${result.installedEvents.join(", ")}`); + if (result.alreadyManaged) { + console.log(`(replaced previously-installed agentwatch stanzas)`); + } + process.exit(0); + } + if (sub === "uninstall") { + const result = uninstallClaudeHooks(); + if (result.removedEvents.length === 0) { + console.log(`no agentwatch hook stanzas found in ${result.settingsPath}`); + } else { + console.log(`removed ${result.removedEvents.length} hook stanzas from ${result.settingsPath}`); + console.log(`events: ${result.removedEvents.join(", ")}`); + } + process.exit(0); + } + if (sub === "status" || sub === undefined) { + const status = claudeHooksStatus(); + console.log(`claude hooks: ${status.status}`); + console.log(`settings: ${status.settingsPath}`); + if (status.managedEvents.length > 0) { + console.log(`installed: ${status.managedEvents.join(", ")}`); + } + if (status.missingEvents.length > 0) { + console.log(`missing: ${status.missingEvents.join(", ")}`); + } + process.exit(0); + } + process.stderr.write( + `agentwatch hooks: unknown subcommand "${sub}" (use install | uninstall | status)\n`, + ); + process.exit(2); +} + +if (arg === "prune") { + const { openStore } = await import("./store/index.js"); + const days = Number(parseFlag("--older-than-days") ?? "90"); + if (!Number.isFinite(days) || days < 0) { + process.stderr.write( + `[agentwatch] prune: --older-than-days must be a non-negative number, got ${days}\n`, + ); + process.exit(2); + } + const store = openStore(); + const result = store.prune({ olderThanDays: days }); + const stats = store.stats(); + store.close(); + console.log( + `pruned ${result.deletedEvents} events / ${result.deletedSessions} sessions older than ${days}d ` + + `(${stats.events} events / ${stats.sessions} sessions / ${(stats.dbBytes / 1_048_576).toFixed(1)} MB remaining)`, + ); + process.exit(0); +} + if (arg === "doctor") { const { detectAgents } = await import("./adapters/detect.js"); const { detectWorkspaceRoot } = await import("./util/workspace.js"); + const { claudeHooksStatus } = await import("./adapters/claude-hooks-install.js"); const agents = detectAgents(); console.log(`workspace: ${detectWorkspaceRoot()}\n`); console.log("agents:"); for (const a of agents) { const mark = a.present ? "●" : "○"; - const status = a.present ? "installed" : "not detected"; - console.log(` ${mark} ${a.label.padEnd(14)} ${status}`); + const status = !a.present + ? "not detected" + : a.instrumented + ? "installed (events captured)" + : "detected (events not yet captured — help us ship this)"; + console.log(` ${mark} ${a.label.padEnd(18)} ${status}`); if (a.configPath) console.log(` config: ${a.configPath}`); } + const notInstrumented = agents.filter((a) => a.present && !a.instrumented); + if (notInstrumented.length > 0) { + console.log(""); + console.log("Agents detected but not yet instrumented:"); + for (const a of notInstrumented) { + console.log(` - ${a.label}`); + } + } + console.log(""); + const hooks = claudeHooksStatus(); + console.log(`claude code hooks: ${hooks.status}`); + if (hooks.status === "partial") { + console.log(` missing: ${hooks.missingEvents.join(", ")}`); + } + if (hooks.status !== "installed") { + console.log(` install with: agentwatch hooks install`); + } process.exit(0); } -render(<App />); +/** Headless mode — start the web server + adapters but no TUI. Useful for + * running on a cloud box and pointing your browser at it over LAN. */ +if (arg === "serve") { + const { startServer } = await import("./server/index.js"); + const { startAllAdapters, stopAllAdapters } = await import( + "./adapters/registry.js" + ); + const { detectWorkspaceRoot } = await import("./util/workspace.js"); + const { clampTs } = await import("./schema.js"); + const { openStore, wrapSinkWithStore } = await import("./store/index.js"); + const workspace = detectWorkspaceRoot(); + const host = parseFlag("--host") ?? process.env.AGENTWATCH_HOST ?? "127.0.0.1"; + const port = Number(parseFlag("--port") ?? process.env.AGENTWATCH_PORT ?? 3456); + const { addEventToServer } = await import("./server/index.js"); + let store: ReturnType<typeof openStore> | null = null; + try { + store = openStore(); + } catch (err) { + process.stderr.write( + `[agentwatch] event store unavailable: ${String(err)}\n`, + ); + } + const server = await startServer({ + host, + port, + ...(store ? { store } : {}), + }); + const innerSink = { + emit: (e: import("./schema.js").AgentEvent) => { + e.ts = clampTs(e.ts); + addEventToServer(server, e); + server.broadcaster.emitEvent(e); + }, + enrich: (eventId: string, patch: Partial<import("./schema.js").EventDetails>) => { + for (const bucket of server.byAgent.values()) { + const target = bucket.find((x) => x.id === eventId); + if (target) { + target.details = { ...(target.details ?? {}), ...patch }; + break; + } + } + server.broadcaster.emitEnrich(eventId, patch); + }, + }; + const { withClassifier } = await import("./classify/index.js"); + const { withClaudeHookDedup } = await import("./adapters/hooks-dedup.js"); + const persistSink = store ? wrapSinkWithStore(innerSink, store) : innerSink; + const classifiedSink = withClassifier(persistSink); + const sink = withClaudeHookDedup(classifiedSink); + server.setHookSink(sink); + const adapters = startAllAdapters(sink, workspace); + onShutdown(() => stopAllAdapters(adapters)); + onShutdown(() => server.stop()); + if (store) onShutdown(() => store?.close()); + process.stderr.write(`[agentwatch] serving ${server.url}\n`); + // Signal handling happens at the bottom of this file via the global + // shutdown-hooks wiring; the serve path just registers its cleanup. + await new Promise(() => undefined); +} + +function parseFlag(name: string): string | undefined { + const idx = process.argv.indexOf(name); + if (idx === -1) return undefined; + const val = process.argv[idx + 1]; + return val && !val.startsWith("--") ? val : undefined; +} + +enterAltScreen(); + +/** Single shutdown path — restore the terminal, drain every registered + * hook (adapters, web server, triggers watcher), then exit. Idempotent: + * a second signal mid-drain is swallowed by `runShutdownHooks`. */ +let shuttingDown = false; +async function shutdown(code: number): Promise<void> { + if (shuttingDown) return; + shuttingDown = true; + restoreTerminal(); + try { + await runShutdownHooks(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] shutdown error:", err); + } + process.exit(code); +} + +for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(sig, () => { + void shutdown(0); + }); +} +// `exit` fires synchronously and can't await — best-effort terminal reset. +process.on("exit", () => { + restoreTerminal(); +}); + +if (arg !== "serve") { + const { waitUntilExit } = render(<App />); + waitUntilExit() + .catch(() => { + // Ink sometimes rejects on Ctrl-C; shutdown handler covers it. + }) + .finally(() => { + void shutdown(0); + }); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..6e1f3bd --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,635 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { claudeProjectsDir } from "../util/workspace.js"; +import { codexSessionsDir, translateCodexLine } from "../adapters/codex.js"; +import { translateClaudeLine } from "../adapters/claude-code.js"; +import { translateSession as translateOpenClawLine } from "../adapters/openclaw.js"; +import { + translateHermesMessage, + translateHermesSessionEnd, + translateHermesSessionStart, + type HermesMessage, + type HermesSession, +} from "../adapters/hermes.js"; +import type { AgentEvent } from "../schema.js"; +import { VERSION } from "../util/version.js"; + +/** + * agentwatch MCP server. Exposes the user's local agent history so + * running agents (Claude Code, Cursor, Codex, OpenClaw, Hermes) can + * look up what they — or other agents — did before. Turns agentwatch + * from "viewer" into "cross-session memory substrate". + * + * Transport: stdio. Run via `agentwatch mcp`. + * + * Tools: + * - list_recent_sessions → [{agent, sessionId, project, lastActivity, sizeBytes}] + * - get_session_events → raw jsonl lines for a session + * - search_sessions → grep across all session files + * - get_tool_usage_stats → per-tool invocation counts + durations + errors + * - get_session_cost → per-session cost, token breakdown, turn count + */ + +type McpAgent = "claude-code" | "codex" | "gemini" | "openclaw" | "hermes"; + +interface SessionRef { + agent: McpAgent; + sessionId: string; + project: string; + /** File path for JSONL agents; DB path for hermes. */ + path: string; + lastActivity: number; + sizeBytes: number; +} + +export async function runMcpServer(): Promise<void> { + const server = new McpServer({ + name: "agentwatch", + version: VERSION, + }); + + server.registerTool( + "list_recent_sessions", + { + title: "List recent agent sessions", + description: + "List the most recent local agent sessions across Claude Code, Codex, Gemini, OpenClaw, and Hermes, newest first. Use to find a session to inspect.", + inputSchema: { + limit: z.number().int().min(1).max(100).optional(), + }, + }, + async ({ limit }) => { + const sessions = listAllSessions().slice(0, limit ?? 20); + const rows = sessions.map((s) => ({ + agent: s.agent, + sessionId: s.sessionId, + project: s.project, + lastActivity: new Date(s.lastActivity).toISOString(), + sizeBytes: s.sizeBytes, + })); + return { + content: [{ type: "text", text: JSON.stringify(rows, null, 2) }], + }; + }, + ); + + server.registerTool( + "get_session_events", + { + title: "Get raw events for a session", + description: + "Return the raw events for a given session ID. JSONL for file-based agents (Claude/Codex/Gemini/OpenClaw); Hermes messages are serialized as one JSON object per line. Use after list_recent_sessions to drill into a session.", + inputSchema: { + sessionId: z.string(), + maxBytes: z.number().int().min(1024).max(10_000_000).optional(), + }, + }, + async ({ sessionId, maxBytes }) => { + const cap = maxBytes ?? 500_000; + const match = listAllSessions().find((s) => s.sessionId === sessionId); + if (!match) { + return { + isError: true, + content: [ + { type: "text", text: `session ${sessionId} not found` }, + ], + }; + } + const raw = + match.agent === "hermes" + ? dumpHermesSessionJsonl(match) + : safeReadFile(match.path); + const trimmed = raw.length > cap ? raw.slice(raw.length - cap) : raw; + return { content: [{ type: "text", text: trimmed }] }; + }, + ); + + server.registerTool( + "search_sessions", + { + title: "Search across all sessions", + description: + "Substring search across all local agent session files. Returns matching sessions with the first few matching lines. Covers Claude, Codex, Gemini, OpenClaw, and Hermes.", + inputSchema: { + query: z.string().min(1), + limit: z.number().int().min(1).max(50).optional(), + }, + }, + async ({ query, limit }) => { + const needle = query.toLowerCase(); + const out: { session: string; agent: string; line: string }[] = []; + const cap = limit ?? 20; + for (const s of listAllSessions()) { + if (out.length >= cap) break; + const raw = + s.agent === "hermes" ? dumpHermesSessionJsonl(s) : safeReadFile(s.path); + if (!raw) continue; + for (const line of raw.split("\n")) { + if (line.toLowerCase().includes(needle)) { + out.push({ + session: s.sessionId, + agent: s.agent, + line: line.slice(0, 500), + }); + if (out.length >= cap) break; + } + } + } + return { + content: [{ type: "text", text: JSON.stringify(out, null, 2) }], + }; + }, + ); + + server.registerTool( + "get_tool_usage_stats", + { + title: "Tool usage statistics", + description: + "Aggregate tool invocation counts, total duration, and error counts. If sessionId is given, stats are scoped to that session; otherwise scoped to the N most recently active sessions across all agents (default 50).", + inputSchema: { + sessionId: z.string().optional(), + limit: z.number().int().min(1).max(500).optional(), + }, + }, + async ({ sessionId, limit }) => { + const sessions = sessionId + ? listAllSessions().filter((s) => s.sessionId === sessionId) + : listAllSessions().slice(0, limit ?? 50); + if (sessions.length === 0) { + return { + isError: true, + content: [ + { + type: "text", + text: sessionId + ? `session ${sessionId} not found` + : "no sessions found", + }, + ], + }; + } + type Stat = { + tool: string; + count: number; + totalDurationMs: number; + errorCount: number; + }; + const stats = new Map<string, Stat>(); + let turns = 0; + let scannedSessions = 0; + for (const s of sessions) { + const events = parseSession(s); + scannedSessions += 1; + for (const e of events) { + if (e.type === "prompt" || e.type === "response") turns += 1; + const tool = e.tool; + if (!tool) continue; + let row = stats.get(tool); + if (!row) { + row = { tool, count: 0, totalDurationMs: 0, errorCount: 0 }; + stats.set(tool, row); + } + row.count += 1; + if (e.details?.durationMs) row.totalDurationMs += e.details.durationMs; + if (e.details?.toolError) row.errorCount += 1; + } + } + const sorted = Array.from(stats.values()).sort((a, b) => b.count - a.count); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { scannedSessions, turns, tools: sorted }, + null, + 2, + ), + }, + ], + }; + }, + ); + + server.registerTool( + "get_session_cost", + { + title: "Session cost + token breakdown", + description: + "Return total cost (USD), token counts broken down by input / cache read / cache create / output, and turn count for a given session.", + inputSchema: { + sessionId: z.string(), + }, + }, + async ({ sessionId }) => { + const match = listAllSessions().find((s) => s.sessionId === sessionId); + if (!match) { + return { + isError: true, + content: [ + { type: "text", text: `session ${sessionId} not found` }, + ], + }; + } + const events = parseSession(match); + let totalCost = 0; + let input = 0; + let cacheRead = 0; + let cacheCreate = 0; + let output = 0; + let turns = 0; + const byModel = new Map<string, number>(); + for (const e of events) { + const d = e.details; + if (!d) continue; + if (d.cost) { + totalCost += d.cost; + const model = d.model ?? "unknown"; + byModel.set(model, (byModel.get(model) ?? 0) + d.cost); + } + if (d.usage) { + input += d.usage.input; + cacheRead += d.usage.cacheRead; + cacheCreate += d.usage.cacheCreate; + output += d.usage.output; + turns += 1; + } + } + const result = { + agent: match.agent, + sessionId, + project: match.project, + totalCostUsd: Number(totalCost.toFixed(6)), + turns, + tokens: { input, cacheRead, cacheCreate, output }, + byModel: Object.fromEntries( + Array.from(byModel.entries()).map(([m, c]) => [ + m, + Number(c.toFixed(6)), + ]), + ), + }; + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +function safeReadFile(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +/** Serialize a hermes session — the session row plus every message — + * as one JSON object per line so `get_session_events` and + * `search_sessions` can treat it like any other JSONL source. */ +function dumpHermesSessionJsonl(ref: SessionRef): string { + const db = openHermesDb(ref.path); + if (!db) return ""; + try { + const session = db + .prepare("SELECT * FROM sessions WHERE id = ?") + .get(ref.sessionId) as Record<string, unknown> | undefined; + const messages = db + .prepare( + "SELECT id, session_id, role, content, tool_call_id, tool_calls, tool_name, " + + "timestamp, token_count, finish_reason, reasoning " + + "FROM messages WHERE session_id = ? ORDER BY id", + ) + .all(ref.sessionId) as Record<string, unknown>[]; + const lines: string[] = []; + if (session) lines.push(JSON.stringify({ kind: "session", ...session })); + for (const m of messages) lines.push(JSON.stringify({ kind: "message", ...m })); + return lines.join("\n"); + } catch { + return ""; + } finally { + try { + db.close(); + } catch { + // best-effort + } + } +} + +function openHermesDb(path: string) { + try { + const db = new Database(path, { readonly: true, fileMustExist: true }); + db.pragma("journal_mode = WAL"); + db.pragma("busy_timeout = 2000"); + return db; + } catch { + return null; + } +} + +/** Read a session, translate every line via the relevant adapter, + * and return AgentEvents. Unreadable / malformed lines are silently + * skipped. */ +function parseSession(s: SessionRef): AgentEvent[] { + if (s.agent === "hermes") return parseHermesSession(s); + if (s.agent === "gemini") { + // Gemini sessions are single-JSON not JSONL, and we don't yet + // translate them to AgentEvents for stats purposes. Return empty + // so get_tool_usage_stats / get_session_cost produce honest zeroes + // rather than fake data. Raw content still reachable via + // get_session_events. + return []; + } + const raw = safeReadFile(s.path); + if (!raw) return []; + const out: AgentEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (!obj || typeof obj !== "object") continue; + const record = obj as Record<string, unknown>; + let e: AgentEvent | null = null; + if (s.agent === "claude-code") + e = translateClaudeLine(record, s.sessionId, s.project); + else if (s.agent === "codex") + e = translateCodexLine(record, s.sessionId, s.project); + else if (s.agent === "openclaw") + e = translateOpenClawLine(record, s.project || "unknown", s.sessionId); + if (e) out.push(e); + } + return out; +} + +function parseHermesSession(s: SessionRef): AgentEvent[] { + const db = openHermesDb(s.path); + if (!db) return []; + try { + const session = db + .prepare( + "SELECT id, source, user_id, model, parent_session_id, started_at, ended_at, " + + "end_reason, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, " + + "actual_cost_usd, estimated_cost_usd FROM sessions WHERE id = ?", + ) + .get(s.sessionId) as HermesSession | undefined; + const messages = db + .prepare( + "SELECT id, session_id, role, content, tool_call_id, tool_calls, tool_name, " + + "timestamp, token_count, finish_reason, reasoning " + + "FROM messages WHERE session_id = ? ORDER BY id", + ) + .all(s.sessionId) as HermesMessage[]; + const out: AgentEvent[] = []; + if (session) { + out.push(translateHermesSessionStart(session, s.path)); + if (session.ended_at !== null) { + out.push(translateHermesSessionEnd(session, s.path)); + } + } + for (const m of messages) { + const e = translateHermesMessage(m, s.path); + if (e) out.push(e); + } + return out; + } catch { + return []; + } finally { + try { + db.close(); + } catch { + // best-effort + } + } +} + +function listAllSessions(): SessionRef[] { + const out: SessionRef[] = []; + try { + const cdir = claudeProjectsDir(); + for (const proj of readdirSync(cdir)) { + const projPath = join(cdir, proj); + try { + for (const f of readdirSync(projPath)) { + if (!f.endsWith(".jsonl")) continue; + const full = join(projPath, f); + const s = statSync(full); + out.push({ + agent: "claude-code", + sessionId: f.replace(/\.jsonl$/, ""), + project: projectFromClaudeDir(proj), + path: full, + lastActivity: s.mtimeMs, + sizeBytes: s.size, + }); + } + } catch { + /* unreadable project */ + } + } + } catch { + /* no claude */ + } + try { + const cdir = codexSessionsDir(); + walkCodex(cdir, out); + } catch { + /* no codex */ + } + try { + const gdir = join(process.env.HOME ?? "", ".gemini", "tmp"); + walkGemini(gdir, out); + } catch { + /* no gemini */ + } + try { + walkOpenClaw(resolveOpenClawRoot(), out); + } catch { + /* no openclaw */ + } + try { + walkHermes(resolveHermesDbPath(), out); + } catch { + /* no hermes */ + } + out.sort((a, b) => b.lastActivity - a.lastActivity); + return out; +} + +function resolveOpenClawRoot(): string { + return join(homedir(), ".openclaw"); +} + +function resolveHermesDbPath(): string { + const explicit = process.env.HERMES_DB_PATH?.trim(); + if (explicit && explicit.length > 0) return explicit; + const hermesHome = process.env.HERMES_HOME?.trim(); + const base = + hermesHome && hermesHome.length > 0 + ? hermesHome + : join(homedir(), ".hermes"); + return join(base, "state.db"); +} + +function walkOpenClaw(root: string, out: SessionRef[]): void { + if (!existsSync(root)) return; + const agentsDir = join(root, "agents"); + let agents: string[]; + try { + agents = readdirSync(agentsDir); + } catch { + return; + } + for (const agent of agents) { + const sessionsDir = join(agentsDir, agent, "sessions"); + let files: string[]; + try { + files = readdirSync(sessionsDir); + } catch { + continue; + } + for (const name of files) { + if (!name.endsWith(".jsonl")) continue; + const full = join(sessionsDir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + out.push({ + agent: "openclaw", + sessionId: name.replace(/\.jsonl$/, ""), + project: agent, + path: full, + lastActivity: st.mtimeMs, + sizeBytes: st.size, + }); + } + } +} + +function walkHermes(dbPath: string, out: SessionRef[]): void { + if (!existsSync(dbPath)) return; + const db = openHermesDb(dbPath); + if (!db) return; + try { + const st = statSync(dbPath); + type Row = { + id: string; + source: string | null; + message_count: number | null; + started_at: number; + ended_at: number | null; + }; + const rows = db + .prepare( + "SELECT id, source, message_count, started_at, ended_at FROM sessions", + ) + .all() as Row[]; + for (const r of rows) { + const lastSec = r.ended_at ?? r.started_at; + out.push({ + agent: "hermes", + sessionId: r.id, + project: r.source ?? "hermes", + path: dbPath, + lastActivity: Math.floor(lastSec * 1000) || st.mtimeMs, + sizeBytes: r.message_count ?? 0, + }); + } + } catch { + /* best-effort */ + } finally { + try { + db.close(); + } catch { + // best-effort + } + } +} + +function walkGemini(dir: string, out: SessionRef[]): void { + let projects: string[]; + try { + projects = readdirSync(dir); + } catch { + return; + } + for (const project of projects) { + const chatsDir = join(dir, project, "chats"); + let files: string[]; + try { + files = readdirSync(chatsDir); + } catch { + continue; + } + for (const name of files) { + if (!name.endsWith(".json")) continue; + const full = join(chatsDir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + const base = name.replace(/\.json$/, ""); + const m = base.match(/^session-[0-9T:\-]+-(.+)$/); + out.push({ + agent: "gemini", + sessionId: m?.[1] ?? base, + project, + path: full, + lastActivity: st.mtimeMs, + sizeBytes: st.size, + }); + } + } +} + +function walkCodex(dir: string, out: SessionRef[]): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + const full = join(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + walkCodex(full, out); + } else if (st.isFile() && /^rollout-.*\.jsonl$/.test(name)) { + const m = name.match(/rollout-[0-9T:\-.]+-(.+)\.jsonl$/); + out.push({ + agent: "codex", + sessionId: m?.[1] ?? name, + project: "", + path: full, + lastActivity: st.mtimeMs, + sizeBytes: st.size, + }); + } + } +} + +function projectFromClaudeDir(dir: string): string { + const segs = dir.split("-").filter(Boolean); + return segs[segs.length - 1] ?? dir; +} diff --git a/src/schema.ts b/src/schema.ts index 65d42cb..1f95216 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -4,6 +4,12 @@ export type AgentName = | "cursor" | "gemini" | "openclaw" + | "hermes" + | "aider" + | "cline" + | "continue" + | "windsurf" + | "goose" | "unknown"; export type EventType = @@ -14,9 +20,115 @@ export type EventType = | "shell_exec" | "prompt" | "response" + | "compaction" + | "parse_error" | "session_start" | "session_end"; +export interface EventDetails { + /** Full prompt or response text, untruncated. */ + fullText?: string; + /** Extended-thinking block content if present. */ + thinking?: string; + /** Full tool_use input object for tool_call / shell_exec / file_* events. */ + toolInput?: Record<string, unknown>; + /** Matches the tool_use_id in the jsonl so downstream correlators can + * pair this event with its tool_result. */ + toolUseId?: string; + /** Full project/session path of the originating file. */ + source?: string; + /** Token usage from an assistant turn (input / cache / output). */ + usage?: { + input: number; + cacheCreate: number; + cacheRead: number; + output: number; + }; + /** Computed USD cost for this turn. */ + cost?: number; + /** Model id that produced this event. */ + model?: string; + /** Captured tool_result content (stdout / file body / search matches). */ + toolResult?: string; + /** Milliseconds between tool_use emission and matched tool_result. */ + durationMs?: number; + /** True if the matched tool_result had is_error set. */ + toolError?: boolean; + /** Subagent id extracted from a Claude `Agent` tool_result. + * Events spawned by that run are stored in sessionId = `agent-<id>`. */ + subAgentId?: string; + /** Set when this event represents one agent invoking another via the + * child agent's CLI (e.g. `codex exec`, `gemini -p`). The parent + * event is the outer Bash / shell_exec; the spawned child agent's + * session events get linked back via `parentSpawnId` (AUR-200). */ + agentCall?: { + callee: AgentName; + /** Extracted prompt argument when we can parse it (`-p ...`, + * `exec ...`, etc.). Undefined when the invocation was free-form. */ + prompt?: string; + /** Sub-shape of the call: `exec` is "give a prompt and exit", + * `chat` is interactive REPL, `unknown` is a generic invocation + * whose semantics we couldn't classify. */ + kind: "exec" | "chat" | "unknown"; + /** Optional model the child was invoked with (e.g. `ollama run llama3`). */ + model?: string; + }; + /** Linked back to the parent agent_call event id when this event + * belongs to a session that was spawned by a Bash(<agent-cli>) call. + * Set on the *first* event of the spawned session — descendants + * inherit by sessionId. */ + parentSpawnId?: string; + /** Marks an event as belonging to a scheduled task — either an + * OpenClaw cron job or a periodic heartbeat run. AUR-204+. */ + scheduled?: { + kind: "cron" | "heartbeat"; + /** Cron job id from `~/.openclaw/cron/jobs.json` (cron only). */ + jobId?: string; + /** Agent id the job/heartbeat is tied to (`main`, `content`, …). */ + agentId?: string; + /** Human label — job name for cron, task name for heartbeat. */ + label?: string; + /** Freeform schedule string: `every 5m`, a 5-field cron expression, + * or `at <iso>`. Source-of-truth is the openclaw jobs.json. */ + schedule?: string; + /** ms-since-epoch this scheduled instance was supposed to fire. */ + scheduledAtMs?: number; + /** Per-run identifier when the runtime emits one + * (e.g. `cron:<jobId>:run:<runId>`). */ + runId?: string; + }; + /** AUR-228: number of unparseable lines we've seen for this session. + * Carried on a synthetic `parse_error` event so operators can see + * they're missing context. */ + parseErrorCount?: number; + /** Truncated preview of the most recent unparseable line. */ + parseErrorSample?: string; + /** Activity category — one of ACTIVITY_CATEGORIES. Heuristically assigned + * on emit by the classify wrapper (AUR-264). Used by the per-session + * and per-project activity views to answer "where is my spend going?". */ + category?: + | "coding" + | "debugging" + | "exploration" + | "planning" + | "refactor" + | "testing" + | "docs" + | "chat" + | "config" + | "review" + | "devops" + | "research"; +} + +/** Sink passed to adapters. Adapters emit new events and may later + * enrich an already-emitted event (e.g. attaching a tool_result to the + * original tool_use). */ +export interface EventSink { + emit: (event: AgentEvent) => void; + enrich: (eventId: string, patch: Partial<EventDetails>) => void; +} + export interface AgentEvent { id: string; ts: string; @@ -29,6 +141,19 @@ export interface AgentEvent { promptId?: string; sessionId?: string; riskScore: number; + details?: EventDetails; +} + +/** Clamp an ISO timestamp so future-dated events don't break sort order. + * System clock skew between agent machines + our TUI can produce `ts` + * values ahead of `Date.now()`; we cap at now + 60s to accommodate + * minor drift without letting a broken clock poison the timeline. */ +export function clampTs(ts: string): string { + const t = new Date(ts).getTime(); + if (!Number.isFinite(t)) return new Date().toISOString(); + const now = Date.now(); + if (t > now + 60_000) return new Date(now).toISOString(); + return ts; } export function riskOf(type: EventType, path?: string, cmd?: string): number { @@ -46,5 +171,6 @@ export function riskOf(type: EventType, path?: string, cmd?: string): number { return 2; } if (type === "tool_call") return 3; + if (type === "parse_error") return 1; return 1; } diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..91e195d --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,218 @@ +import Fastify from "fastify"; +import fastifyStatic from "@fastify/static"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { existsSync } from "node:fs"; +import type { AgentEvent } from "../schema.js"; +import { SseBroadcaster } from "./sse.js"; +import { registerEventRoutes } from "./routes/events.js"; +import { registerProjectRoutes } from "./routes/projects.js"; +import { registerSessionRoutes } from "./routes/sessions.js"; +import { registerAgentRoutes } from "./routes/agents.js"; +import { registerPermissionRoutes } from "./routes/permissions.js"; +import { registerCronRoutes } from "./routes/cron.js"; +import { registerSearchRoutes } from "./routes/search.js"; +import { registerClaudeHooksRoute } from "../adapters/claude-hooks.js"; +import { registerYieldRoutes } from "./routes/yield.js"; +import { registerActivityRoutes } from "./routes/activity.js"; +import type { EventSink } from "../schema.js"; +import type { EventStore } from "../store/sqlite.js"; +import { registerConfigRoutes } from "./routes/config.js"; +import { registerTrendsRoutes } from "./routes/trends.js"; +import { registerDiffRoutes } from "./routes/diffs.js"; +import { registerReplayRoutes } from "./routes/replay.js"; +import { VERSION } from "../util/version.js"; + +/** + * Per-agent cap — each agent's bucket is bounded, so one chatty agent + * (claude-code emits ~50k events on boot backfill) can't evict smaller + * but equally interesting agents (gemini, codex, openclaw, hermes). + */ +const PER_AGENT_CAP = 10_000; + +export interface ServerHandle { + url: string; + broadcaster: SseBroadcaster; + /** Per-agent buckets; oldest-first within each. */ + byAgent: Map<string, AgentEvent[]>; + /** Flat merged view for callers expecting one array. Rebuilt lazily. */ + events: AgentEvent[]; + /** Rebuild `events` from `byAgent`. Cheap enough at our scale. */ + rebuildFlat: () => void; + /** Persistent SQLite store, if one was passed at startup. Routes that + * need full history (e.g. search history mode) read from this; the + * in-memory ring buffer remains the source of truth for the SSE live + * stream. */ + store?: EventStore; + /** Set after adapters start so the Claude hooks route has somewhere + * to forward incoming hook payloads. Until set, the hooks route + * responds with `{ok:false, reason:"hooks not ready"}` — the hook + * curl exits 0 either way so Claude never blocks. */ + setHookSink: (sink: EventSink) => void; + stop: () => Promise<void>; +} + +export interface StartServerOptions { + host?: string; + port?: number; + events?: AgentEvent[]; // optional; kept for back-compat + store?: EventStore; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 3456; + +/** Add an event into a per-agent bucket + mark the flat view dirty. + * The flat array is rebuilt lazily on the next API request — that + * defers the O(n log n) sort out of the hot emit path. */ +export function addEventToServer(handle: ServerHandle, e: AgentEvent): void { + let bucket = handle.byAgent.get(e.agent); + if (!bucket) { + bucket = []; + handle.byAgent.set(e.agent, bucket); + } + bucket.push(e); + if (bucket.length > PER_AGENT_CAP) { + bucket.splice(0, 1_000); + } + // Invalidate — flat array will be rebuilt lazily. + (handle as { flatDirty?: boolean }).flatDirty = true; +} + +/** Resolve the web bundle directory. + * After production build: dist/index.js → dist/web/ (sibling). + * Dev (tsx from src/server/index.ts): walk two levels up → dist/web/ + * (built once by `npm run build:web` during dev). */ +function resolveWebDist(): string | null { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "web"), // built: dist/index.js → dist/web + join(here, "..", "dist", "web"), // dev: src/server → dist/web + join(here, "..", "..", "dist", "web"), // nested fallback + ]; + for (const c of candidates) if (existsSync(c)) return c; + return null; +} + +export async function startServer(opts: StartServerOptions): Promise<ServerHandle> { + const host = opts.host ?? DEFAULT_HOST; + const port = opts.port ?? DEFAULT_PORT; + const broadcaster = new SseBroadcaster(); + + const byAgent = new Map<string, AgentEvent[]>(); + const events: AgentEvent[] = opts.events ?? []; + + function rebuildFlat(): void { + events.length = 0; + for (const bucket of byAgent.values()) { + for (const e of bucket) events.push(e); + } + events.sort((a, b) => (a.ts < b.ts ? -1 : 1)); + } + + let handle: ServerHandle | null = null; + + const app = Fastify({ logger: false }); + + // Rebuild flat view on every API request that actually reads events. + // Per-request cost is O(n) merge + O(n log n) sort — ~5ms at 10k + // events, invisible in user latency. + app.addHook("onRequest", async (req) => { + if (!req.url.startsWith("/api/")) return; + if (req.url === "/api/events/stream") return; // SSE doesn't read flat + const dirty = (handle as { flatDirty?: boolean } | null)?.flatDirty; + if (dirty !== false) { + rebuildFlat(); + if (handle) (handle as { flatDirty?: boolean }).flatDirty = false; + } + }); + + // CORS for dev: allow localhost:5173 (Vite) to hit us during development. + app.addHook("onSend", async (_req, reply, payload) => { + reply.header("Access-Control-Allow-Origin", "*"); + reply.header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS"); + reply.header("Access-Control-Allow-Headers", "Content-Type"); + return payload; + }); + app.options("/*", async (_req, reply) => { + reply.code(204).send(); + }); + + // Health + version + app.get("/api/health", async () => ({ ok: true, version: VERSION })); + + // SSE stream + app.get("/api/events/stream", async (req, reply) => { + reply.raw.setHeader("Content-Type", "text/event-stream"); + reply.raw.setHeader("Cache-Control", "no-cache"); + reply.raw.setHeader("Connection", "keep-alive"); + reply.raw.setHeader("X-Accel-Buffering", "no"); + reply.raw.flushHeaders?.(); + const clientId = broadcaster.attach(reply.raw); + // Heartbeat is owned by the broadcaster — a single tick for N clients + // that shares dead-socket detection with `broadcast()`. + req.raw.on("close", () => broadcaster.detach(clientId)); + // Keep the handler alive until the socket closes. + return reply; + }); + + registerEventRoutes(app, events); + registerProjectRoutes(app, events, opts.store); + registerSessionRoutes(app, events, opts.store); + registerAgentRoutes(app, events, byAgent); + registerPermissionRoutes(app); + registerCronRoutes(app, events); + registerSearchRoutes(app, events, opts.store); + registerYieldRoutes(app, opts.store); + registerActivityRoutes(app, opts.store); + // Hooks route reads the sink lazily from the handle so callers can + // wire it after server start. Until set, the route 404s. + let hookSink: EventSink | null = null; + registerClaudeHooksRoute(app, { + emit: (event) => hookSink?.emit(event), + enrich: (id, patch) => hookSink?.enrich(id, patch), + }); + registerConfigRoutes(app); + registerTrendsRoutes(app, events); + registerDiffRoutes(app, events); + registerReplayRoutes(app, events); + + // Static web bundle (if built). + const webDist = resolveWebDist(); + if (webDist) { + await app.register(fastifyStatic, { root: webDist, prefix: "/" }); + // SPA fallback — any unknown route serves index.html. + app.setNotFoundHandler((req, reply) => { + if (req.url.startsWith("/api/")) { + reply.code(404).send({ error: "not found" }); + return; + } + reply.sendFile("index.html"); + }); + } else { + app.get("/", async () => ({ + message: + "agentwatch web UI bundle not built — run `npm run build:web` or `npm run dev:web` to develop", + })); + } + + await app.listen({ host, port }); + const url = `http://${host}:${port}`; + + handle = { + url, + broadcaster, + byAgent, + events, + rebuildFlat, + store: opts.store, + setHookSink: (sink) => { + hookSink = sink; + }, + stop: async () => { + broadcaster.closeAll(); + await app.close(); + }, + }; + return handle; +} diff --git a/src/server/routes/activity.ts b/src/server/routes/activity.ts new file mode 100644 index 0000000..f5c4dcf --- /dev/null +++ b/src/server/routes/activity.ts @@ -0,0 +1,29 @@ +import type { FastifyInstance } from "fastify"; +import type { EventStore } from "../../store/sqlite.js"; + +/** Per-category activity rollups for a session or project. Routes return + * empty arrays when no store is attached or no matching data exists — + * the UI is responsible for showing an empty-state instead of a 404, + * because "this session has zero events of any category" is meaningful. */ +export function registerActivityRoutes( + app: FastifyInstance, + store?: EventStore, +): void { + app.get<{ Params: { id: string } }>( + "/api/sessions/:id/activity", + async (req) => { + const id = decodeURIComponent(req.params.id); + if (!store) return { sessionId: id, buckets: [] }; + return { sessionId: id, buckets: store.activityBySession(id) }; + }, + ); + + app.get<{ Params: { name: string } }>( + "/api/projects/:name/activity", + async (req) => { + const name = decodeURIComponent(req.params.name); + if (!store) return { project: name, buckets: [] }; + return { project: name, buckets: store.activityByProject(name) }; + }, + ); +} diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts new file mode 100644 index 0000000..ab6ee49 --- /dev/null +++ b/src/server/routes/agents.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { detectAgents } from "../../adapters/detect.js"; + +export function registerAgentRoutes( + app: FastifyInstance, + _events: AgentEvent[], + byAgent: Map<string, AgentEvent[]>, +): void { + app.get("/api/agents", async () => { + const agents = detectAgents(); + return { + agents: agents.map((a) => { + const bucket = byAgent.get(a.name); + // Buckets are insertion-order; the max ts may be anywhere + // inside (adapter backfill can replay old and new session + // files interleaved). Walk to find it. + let maxTs: string | null = null; + if (bucket) { + for (const e of bucket) if (!maxTs || e.ts > maxTs) maxTs = e.ts; + } + return { + ...a, + eventCount: bucket?.length ?? 0, + lastEventAt: maxTs, + }; + }), + }; + }); +} diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts new file mode 100644 index 0000000..de19744 --- /dev/null +++ b/src/server/routes/config.ts @@ -0,0 +1,100 @@ +import type { FastifyInstance } from "fastify"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +const CONFIG_DIR = join(homedir(), ".agentwatch"); +const PATHS = { + budgets: join(CONFIG_DIR, "budgets.json"), + anomaly: join(CONFIG_DIR, "anomaly.json"), + triggers: join(CONFIG_DIR, "triggers.json"), +} as const; + +type ConfigKind = keyof typeof PATHS; + +const DEFAULTS: Record<ConfigKind, unknown> = { + budgets: { perSessionUsd: null, perDayUsd: null }, + anomaly: { zScore: 3.5, loopWindow: 20, loopMinRepeats: 3, minSamples: 8 }, + triggers: [], +}; + +function readConfig(kind: ConfigKind): unknown { + const p = PATHS[kind]; + if (!existsSync(p)) return DEFAULTS[kind]; + try { + return JSON.parse(readFileSync(p, "utf8")); + } catch { + return DEFAULTS[kind]; + } +} + +function writeConfig(kind: ConfigKind, value: unknown): void { + const p = PATHS[kind]; + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(value, null, 2), "utf8"); +} + +function validate(kind: ConfigKind, value: unknown): { ok: true } | { ok: false; error: string } { + if (kind === "budgets") { + if (typeof value !== "object" || value == null) return { ok: false, error: "budgets must be an object" }; + const v = value as Record<string, unknown>; + for (const k of ["perSessionUsd", "perDayUsd"]) { + const n = v[k]; + if (n != null && typeof n !== "number") return { ok: false, error: `${k} must be number or null` }; + } + return { ok: true }; + } + if (kind === "anomaly") { + if (typeof value !== "object" || value == null) return { ok: false, error: "anomaly must be an object" }; + const v = value as Record<string, unknown>; + for (const k of ["zScore", "loopWindow", "loopMinRepeats", "minSamples"]) { + if (v[k] != null && typeof v[k] !== "number") return { ok: false, error: `${k} must be number` }; + } + return { ok: true }; + } + if (kind === "triggers") { + if (!Array.isArray(value)) return { ok: false, error: "triggers must be an array" }; + for (let i = 0; i < value.length; i++) { + const t = value[i] as Record<string, unknown>; + if (typeof t !== "object" || t == null) return { ok: false, error: `triggers[${i}] must be an object` }; + if (!t.title || !t.body) + return { ok: false, error: `triggers[${i}] requires title + body` }; + } + return { ok: true }; + } + return { ok: false, error: "unknown config kind" }; +} + +export function registerConfigRoutes(app: FastifyInstance): void { + app.get<{ Params: { kind: string } }>("/api/config/:kind", async (req, reply) => { + const kind = req.params.kind as ConfigKind; + if (!(kind in PATHS)) { + reply.code(404); + return { error: `unknown kind: ${kind}` }; + } + return { + kind, + path: PATHS[kind], + value: readConfig(kind), + defaults: DEFAULTS[kind], + }; + }); + + app.put<{ Params: { kind: string }; Body: unknown }>( + "/api/config/:kind", + async (req, reply) => { + const kind = req.params.kind as ConfigKind; + if (!(kind in PATHS)) { + reply.code(404); + return { error: `unknown kind: ${kind}` }; + } + const v = validate(kind, req.body); + if (!v.ok) { + reply.code(400); + return { error: v.error }; + } + writeConfig(kind, req.body); + return { ok: true, kind, value: req.body }; + }, + ); +} diff --git a/src/server/routes/cron.ts b/src/server/routes/cron.ts new file mode 100644 index 0000000..0dbdc1b --- /dev/null +++ b/src/server/routes/cron.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { readCronJobs } from "../../util/openclaw-cron.js"; +import { readAllHeartbeats } from "../../util/openclaw-heartbeat.js"; + +export function registerCronRoutes(app: FastifyInstance, events: AgentEvent[]): void { + app.get("/api/cron", async () => { + return { + jobs: readCronJobs(), + heartbeats: readAllHeartbeats(), + // events is oldest-first; reverse the last 200 to show newest first. + scheduledEvents: events + .filter((e) => e.details?.scheduled) + .slice(-200) + .reverse(), + }; + }); +} diff --git a/src/server/routes/diffs.ts b/src/server/routes/diffs.ts new file mode 100644 index 0000000..8844f0a --- /dev/null +++ b/src/server/routes/diffs.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; + +/** A diff entry: a file_write / file_change event paired with the nearest + * preceding prompt in the same session. Implements AUR-114 (diff + * attribution) — answers "what user ask caused this write?" */ +interface DiffEntry { + event: AgentEvent; + triggeringPrompt?: AgentEvent; + oldString?: string; + newString?: string; + content?: string; +} + +function isWriteEvent(e: AgentEvent): boolean { + return e.type === "file_write" || e.type === "file_change"; +} + +export function registerDiffRoutes(app: FastifyInstance, events: AgentEvent[]): void { + app.get<{ Params: { id: string } }>("/api/sessions/:id/diffs", async (req, reply) => { + const id = decodeURIComponent(req.params.id); + // events is oldest-first now → already chronological for the session. + const session = events.filter((e) => e.sessionId === id); + if (session.length === 0) { + reply.code(404); + return { error: "session not found" }; + } + + const entries: DiffEntry[] = []; + for (let i = 0; i < session.length; i++) { + const e = session[i]!; + if (!isWriteEvent(e)) continue; + // Walk back to the nearest user prompt in this session. + let triggering: AgentEvent | undefined; + for (let j = i - 1; j >= 0; j--) { + const prev = session[j]!; + if (prev.type === "prompt") { + triggering = prev; + break; + } + } + const input = e.details?.toolInput ?? {}; + const oldString = typeof input.old_string === "string" ? (input.old_string as string) : undefined; + const newString = typeof input.new_string === "string" ? (input.new_string as string) : undefined; + const content = typeof input.content === "string" ? (input.content as string) : undefined; + entries.push({ + event: e, + triggeringPrompt: triggering, + oldString, + newString, + content, + }); + } + + return { + sessionId: id, + diffs: entries, + count: entries.length, + }; + }); +} diff --git a/src/server/routes/events.ts b/src/server/routes/events.ts new file mode 100644 index 0000000..4079a41 --- /dev/null +++ b/src/server/routes/events.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; + +interface EventQuery { + limit?: string; + before?: string; // ISO ts cursor — return events strictly before this ts + project?: string; + agent?: string; + session?: string; + type?: string; + q?: string; // live substring search +} + +export function registerEventRoutes(app: FastifyInstance, events: AgentEvent[]): void { + app.get<{ Querystring: EventQuery }>("/api/events", async (req) => { + const limit = clamp(parseInt(req.query.limit ?? "100", 10) || 100, 1, 50_000); + const beforeMs = req.query.before ? Date.parse(req.query.before) : null; + // Buffer is stored oldest-first for O(1) append. Walk backwards to + // build newest-first results without materializing a reversed copy. + const out: AgentEvent[] = []; + const needle = req.query.q?.toLowerCase(); + for (let i = events.length - 1; i >= 0 && out.length < limit; i--) { + const e = events[i]!; + if (req.query.agent && e.agent !== req.query.agent) continue; + if (req.query.session && e.sessionId !== req.query.session) continue; + if (req.query.type && e.type !== req.query.type) continue; + if (req.query.project) { + const pref = `[${req.query.project}`; + if (!(e.summary ?? "").startsWith(pref)) continue; + } + if (beforeMs && !Number.isNaN(beforeMs)) { + if (new Date(e.ts).getTime() >= beforeMs) continue; + } + if (needle && !matchesLive(e, needle)) continue; + out.push(e); + } + return { + events: out, + total: events.length, + returned: out.length, + }; + }); + + app.get<{ Params: { id: string } }>("/api/events/:id", async (req, reply) => { + const ev = events.find((e) => e.id === req.params.id); + if (!ev) { + reply.code(404); + return { error: "event not found" }; + } + return { event: ev }; + }); +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} + +function matchesLive(e: AgentEvent, needle: string): boolean { + if ((e.summary ?? "").toLowerCase().includes(needle)) return true; + if ((e.path ?? "").toLowerCase().includes(needle)) return true; + if ((e.cmd ?? "").toLowerCase().includes(needle)) return true; + if ((e.tool ?? "").toLowerCase().includes(needle)) return true; + if ((e.agent ?? "").toLowerCase().includes(needle)) return true; + const d = e.details; + if (d?.fullText && d.fullText.toLowerCase().includes(needle)) return true; + if (d?.thinking && d.thinking.toLowerCase().includes(needle)) return true; + return false; +} diff --git a/src/server/routes/permissions.ts b/src/server/routes/permissions.ts new file mode 100644 index 0000000..7e0f098 --- /dev/null +++ b/src/server/routes/permissions.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import { readClaudePermissions } from "../../util/claude-permissions.js"; +import { readCodexPermissions } from "../../util/codex-permissions.js"; +import { readGeminiPermissions } from "../../util/gemini-permissions.js"; +import { readOpenClawConfig } from "../../util/openclaw-config.js"; +import { detectWorkspaceRoot } from "../../util/workspace.js"; + +export function registerPermissionRoutes(app: FastifyInstance): void { + app.get("/api/permissions", async () => { + const workspace = detectWorkspaceRoot(); + return { + claude: readClaudePermissions(workspace), + codex: readCodexPermissions(), + gemini: readGeminiPermissions(), + openclaw: readOpenClawConfig(), + }; + }); +} diff --git a/src/server/routes/projects.ts b/src/server/routes/projects.ts new file mode 100644 index 0000000..075095a --- /dev/null +++ b/src/server/routes/projects.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { buildProjectIndex, buildSessionRows } from "../../util/project-index.js"; +import type { EventStore } from "../../store/sqlite.js"; + +export function registerProjectRoutes(app: FastifyInstance, events: AgentEvent[], store?: EventStore): void { + app.get("/api/projects", async () => { + // If store is available, use listProjects(). It returns exact ProjectSummary + // which matches the shape returned by the legacy buildProjectIndex mapping. + if (store) { + return { projects: store.listProjects() }; + } + const rows = buildProjectIndex(events).map((p) => ({ + name: p.name, + eventCount: p.events, + byAgent: Object.fromEntries(p.byAgent), + sessionIds: Array.from(p.sessions), + cost: p.cost, + lastTs: p.lastTs, + })); + return { projects: rows }; + }); + + app.get<{ Params: { name: string } }>( + "/api/projects/:name/sessions", + async (req) => { + const name = decodeURIComponent(req.params.name); + if (store) { + const sessions = store.listSessions({ project: name }).map((s) => ({ + sessionId: s.sessionId, + agent: s.agent, + project: s.project || name, + eventCount: s.eventCount, + events: s.eventCount, // for consumers expecting SessionRow + cost: s.costUsd, + firstTs: s.firstTs, + lastTs: s.lastTs, + firstPrompt: "", + })); + return { project: name, sessions }; + } + const sessions = buildSessionRows(events, name).map(r => ({ + ...r, + eventCount: r.events, + })); + return { project: name, sessions }; + }, + ); +} diff --git a/src/server/routes/replay.ts b/src/server/routes/replay.ts new file mode 100644 index 0000000..635de1d --- /dev/null +++ b/src/server/routes/replay.ts @@ -0,0 +1,146 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent, AgentName } from "../../schema.js"; +import { spawn } from "node:child_process"; + +interface ReplayBody { + /** User-edited prompt text (defaults to the original if absent). */ + prompt?: string; + /** Optional override of the CLI binary. Handy when the agent binary + * isn't on PATH (e.g. hermes installed into ~/.local/bin). */ + binaryPath?: string; + /** Max wall-clock seconds before we kill the child process. */ + timeoutSec?: number; +} + +/** Agent-aware replay: spawns the appropriate CLI in single-turn exec + * mode with the (possibly edited) prompt and streams stdout+stderr + * back. Implements AUR-116 (replay-with-edited-prompt). + * + * Design boundary: we don't attempt to restore the agent's full + * context/session — this is a fresh single-turn run with the edited + * prompt. That's both safer (can't clobber original session state) + * and matches the dominant use case ("what would the agent say if I + * phrased this differently?"). */ +function argBuilderFor( + agent: AgentName, +): ((prompt: string) => { cmd: string; args: string[] }) | null { + switch (agent) { + case "claude-code": + return (p) => ({ cmd: "claude", args: ["-p", p] }); + case "codex": + return (p) => ({ cmd: "codex", args: ["exec", p] }); + case "gemini": + return (p) => ({ cmd: "gemini", args: ["-p", p] }); + case "hermes": + return (p) => ({ cmd: "hermes", args: ["chat", "-q", p, "-Q", "--max-turns", "1"] }); + case "cursor": + case "openclaw": + case "windsurf": + case "aider": + case "cline": + case "continue": + case "goose": + case "unknown": + return null; + } +} + +export function registerReplayRoutes(app: FastifyInstance, events: AgentEvent[]): void { + app.post<{ Params: { id: string }; Body: ReplayBody }>( + "/api/sessions/:id/replay", + async (req, reply) => { + const id = decodeURIComponent(req.params.id); + const sessionEvents = events.filter((e) => e.sessionId === id); + if (sessionEvents.length === 0) { + reply.code(404); + return { error: "session not found" }; + } + const agent = sessionEvents[0]!.agent; + const builder = argBuilderFor(agent); + if (!builder) { + reply.code(400); + return { error: `replay not supported for agent "${agent}" yet` }; + } + + // Original prompt = first 'prompt' event (events are oldest-first). + const firstPrompt = sessionEvents.find((e) => e.type === "prompt"); + const originalPrompt = firstPrompt?.details?.fullText ?? firstPrompt?.summary ?? ""; + const prompt = (req.body?.prompt ?? originalPrompt).trim(); + if (!prompt) { + reply.code(400); + return { error: "no prompt (body.prompt and no prompt event in session)" }; + } + + const { cmd, args } = builder(prompt); + const binary = req.body?.binaryPath?.trim() || cmd; + const timeoutMs = Math.min(300_000, Math.max(5_000, (req.body?.timeoutSec ?? 60) * 1000)); + + const started = Date.now(); + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + const child = spawn(binary, args, { + env: { ...process.env, PATH: `${process.env.HOME}/.local/bin:${process.env.PATH ?? ""}` }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d) => (stdout += d.toString())); + child.stderr?.on("data", (d) => (stderr += d.toString())); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + resolve({ + ok: false, + agent, + prompt, + command: `${binary} ${args.map((a) => JSON.stringify(a)).join(" ")}`, + durationMs: Date.now() - started, + stdout: truncate(stdout, 40_000), + stderr: truncate(stderr, 40_000), + error: `timed out after ${timeoutMs} ms`, + }); + }, timeoutMs); + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: false, + agent, + prompt, + command: `${binary} ${args.map((a) => JSON.stringify(a)).join(" ")}`, + durationMs: Date.now() - started, + stdout: truncate(stdout, 40_000), + stderr: truncate(stderr, 40_000), + error: String(err), + }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + ok: code === 0, + exitCode: code, + agent, + prompt, + command: `${binary} ${args.map((a) => JSON.stringify(a)).join(" ")}`, + durationMs: Date.now() - started, + stdout: truncate(stdout, 40_000), + stderr: truncate(stderr, 40_000), + }); + }); + }); + }, + ); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + `\n… (${s.length - max} more chars truncated)`; +} diff --git a/src/server/routes/search.ts b/src/server/routes/search.ts new file mode 100644 index 0000000..2093164 --- /dev/null +++ b/src/server/routes/search.ts @@ -0,0 +1,134 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { searchAllSessions } from "../../util/cross-search.js"; +import type { EventStore } from "../../store/sqlite.js"; + +interface SearchBody { + query: string; + mode?: "live" | "cross" | "semantic" | "history"; + limit?: number; + /** Optional ISO timestamps narrowing the window (cross mode only — live + * is ring-buffer scoped already). */ + since?: string; + until?: string; + /** Optional agent allowlist. */ + agents?: string[]; +} + +/* Timestamp extraction lives in cross-search.ts/sniffTs — hits carry + * a `ts` field when the JSONL line included one. */ + +export function registerSearchRoutes( + app: FastifyInstance, + events: AgentEvent[], + store?: EventStore, +): void { + app.post<{ Body: SearchBody }>("/api/search", async (req, reply) => { + const query = (req.body?.query ?? "").trim(); + const mode = req.body?.mode ?? "live"; + const limit = clamp(req.body?.limit ?? 100, 1, 500); + + if (!query) { + return { mode, hits: [] }; + } + + if (mode === "live") { + const needle = query.toLowerCase(); + // events is oldest-first; walk backwards so top hits are newest. + const hits: typeof events = []; + for (let i = events.length - 1; i >= 0 && hits.length < limit; i--) { + const e = events[i]!; + if (matchesLive(e, needle)) hits.push(e); + } + return { mode, hits: hits.map((e) => ({ kind: "live" as const, event: e })) }; + } + + if (mode === "history") { + if (!store) { + return { + mode, + hits: [], + status: "history mode requires a SQLite store — pass --no-web off and ensure ~/.agentwatch is writable", + }; + } + const hits = store.searchFts(query, { limit }).map((h) => ({ + kind: "history" as const, + hit: { + eventId: h.eventId, + sessionId: h.sessionId, + agent: h.agent, + ts: h.ts, + type: h.type, + snippet: h.snippet, + rank: h.rank, + }, + })); + return { mode, hits }; + } + + if (mode === "cross") { + // Pull generously, then apply agent + date filters before capping. + const raw = searchAllSessions(query, Math.max(limit, 300)); + const sinceMs = req.body?.since ? Date.parse(req.body.since) : null; + const untilMs = req.body?.until ? Date.parse(req.body.until) : null; + const agentFilter = req.body?.agents && req.body.agents.length > 0 + ? new Set(req.body.agents) + : null; + const enriched = raw + .filter((h) => { + if (agentFilter && !agentFilter.has(h.agent)) return false; + if (sinceMs != null && h.ts && Date.parse(h.ts) < sinceMs) return false; + if (untilMs != null && h.ts && Date.parse(h.ts) > untilMs) return false; + return true; + }) + .slice(0, limit); + return { + mode, + hits: enriched.map((h) => ({ kind: "cross" as const, hit: h })), + totalScanned: raw.length, + }; + } + + // Semantic: lazy-import so the model download only happens on first request. + try { + const { searchHybrid, hasIndex, indexStats, loadEmbedder, searchBm25Only } = + await import("../../util/semantic-index.js"); + if (!hasIndex() || indexStats().vectors === 0) { + // Bail out to BM25 rather than blocking for a ~80MB model download + // on a web request. Surface a status so the UI can show a hint. + const hits = searchBm25Only(query, limit); + return { + mode, + hits: hits.map((h) => ({ kind: "semantic" as const, hit: h })), + status: "semantic index not built — running BM25 fallback. Build via: agentwatch (press / → semantic mode in TUI)", + }; + } + const embed = await loadEmbedder(); + const qvec = await embed(query); + const hits = await searchHybrid(query, new Float32Array(qvec), limit); + return { + mode, + hits: hits.map((h) => ({ kind: "semantic" as const, hit: h })), + }; + } catch (err) { + reply.code(500); + return { mode, hits: [], error: String(err).slice(0, 200) }; + } + }); +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} + +function matchesLive(e: AgentEvent, needle: string): boolean { + if ((e.summary ?? "").toLowerCase().includes(needle)) return true; + if ((e.path ?? "").toLowerCase().includes(needle)) return true; + if ((e.cmd ?? "").toLowerCase().includes(needle)) return true; + if ((e.tool ?? "").toLowerCase().includes(needle)) return true; + if ((e.agent ?? "").toLowerCase().includes(needle)) return true; + const d = e.details; + if (d?.fullText && d.fullText.toLowerCase().includes(needle)) return true; + if (d?.thinking && d.thinking.toLowerCase().includes(needle)) return true; + return false; +} diff --git a/src/server/routes/sessions.test.ts b/src/server/routes/sessions.test.ts new file mode 100644 index 0000000..ba805a3 --- /dev/null +++ b/src/server/routes/sessions.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import Fastify from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { registerSessionRoutes } from "./sessions.js"; +import { registerProjectRoutes } from "./projects.js"; +import { openStore } from "../../store/sqlite.js"; +import type { EventStore } from "../../store/sqlite.js"; + +describe("SQLite migration routes", () => { + let app: ReturnType<typeof Fastify>; + let store: EventStore; + + beforeEach(() => { + app = Fastify(); + store = openStore({ dbPath: ":memory:" }); + }); + + afterEach(async () => { + store.close(); + await app.close(); + }); + + it("GET /api/sessions/:id returns events from store (even if not in memory)", async () => { + const memoryEvents: AgentEvent[] = []; // empty memory buffer! + + const oldEvent: AgentEvent = { + id: "ev1", + sessionId: "s1", + agent: "claude-code", + ts: "2023-01-01T00:00:00Z", + type: "prompt", + summary: "Hello", + riskScore: 0, + }; + store.insert(oldEvent); + + registerSessionRoutes(app, memoryEvents, store); + + const res = await app.inject({ + method: "GET", + url: "/api/sessions/s1", + }); + + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.sessionId).toBe("s1"); + expect(json.events).toHaveLength(1); + expect(json.events[0].id).toBe("ev1"); + }); + + it("GET /api/projects/:name/sessions returns sessions from store", async () => { + const memoryEvents: AgentEvent[] = []; + + const oldEvent: AgentEvent = { + id: "ev1", + sessionId: "s1", + agent: "claude-code", + ts: "2023-01-01T00:00:00Z", + type: "prompt", + summary: "[myproj] Hello", + riskScore: 0, + }; + store.insert(oldEvent); + + registerProjectRoutes(app, memoryEvents, store); + + const res = await app.inject({ + method: "GET", + url: "/api/projects/myproj/sessions", + }); + + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.project).toBe("myproj"); + expect(json.sessions).toHaveLength(1); + expect(json.sessions[0].sessionId).toBe("s1"); + expect(json.sessions[0].eventCount).toBe(1); + }); +}); diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts new file mode 100644 index 0000000..2ae72bb --- /dev/null +++ b/src/server/routes/sessions.ts @@ -0,0 +1,85 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; +import { attributeTokens, attributeTurns } from "../../util/token-attribution.js"; +import { buildCallGraph } from "../../util/call-graph.js"; +import { buildCompactionSeries } from "../../util/compaction.js"; +import { exportSession, sessionToMarkdown } from "../../util/export.js"; +import type { EventStore } from "../../store/sqlite.js"; + +export function registerSessionRoutes(app: FastifyInstance, events: AgentEvent[], store?: EventStore): void { + // Full events for a session. + app.get<{ Params: { id: string } }>("/api/sessions/:id", async (req, reply) => { + const id = decodeURIComponent(req.params.id); + const sessionEvents = store ? store.listSessionEvents(id) : events.filter((e) => e.sessionId === id); + if (sessionEvents.length === 0) { + reply.code(404); + return { error: "session not found (or events not yet loaded)" }; + } + const first = sessionEvents[sessionEvents.length - 1]; + return { + sessionId: id, + agent: first?.agent, + events: sessionEvents, + }; + }); + + app.get<{ Params: { id: string } }>( + "/api/sessions/:id/tokens", + async (req) => { + const id = decodeURIComponent(req.params.id); + return { + sessionId: id, + breakdown: attributeTokens(store ? store.listSessionEvents(id) : events, id), + turns: attributeTurns(store ? store.listSessionEvents(id) : events, id), + }; + }, + ); + + app.get<{ Params: { id: string } }>( + "/api/sessions/:id/compaction", + async (req) => { + const id = decodeURIComponent(req.params.id); + return { + sessionId: id, + series: buildCompactionSeries(store ? store.listSessionEvents(id) : events, id), + }; + }, + ); + + app.get<{ Params: { id: string } }>( + "/api/sessions/:id/graph", + async (req) => { + const id = decodeURIComponent(req.params.id); + return { + sessionId: id, + graph: buildCallGraph(store ? store.listSessionEvents(id) : events, id), + }; + }, + ); + + // Export — either as stream or as written file path. + app.get<{ Params: { id: string }; Querystring: { format?: string; inline?: string } }>( + "/api/sessions/:id/export", + async (req, reply) => { + const id = decodeURIComponent(req.params.id); + const sessionEvents = store ? store.listSessionEvents(id) : events.filter((e) => e.sessionId === id); + if (sessionEvents.length === 0) { + reply.code(404); + return { error: "session not found" }; + } + const agent = sessionEvents[0]?.agent ?? "unknown"; + const format = req.query.format === "json" ? "json" : "md"; + // inline=1: return content directly, don't write to disk. + if (req.query.inline === "1") { + if (format === "json") { + reply.header("Content-Type", "application/json"); + return { sessionId: id, agent, events: sessionEvents }; + } + reply.header("Content-Type", "text/markdown"); + return sessionToMarkdown(sessionEvents, id, agent); + } + const res = exportSession(sessionEvents, id, agent); + return res; + }, + ); +} diff --git a/src/server/routes/trends.ts b/src/server/routes/trends.ts new file mode 100644 index 0000000..22aaadd --- /dev/null +++ b/src/server/routes/trends.ts @@ -0,0 +1,92 @@ +import type { FastifyInstance } from "fastify"; +import type { AgentEvent } from "../../schema.js"; + +/** Bucket events by day. Returns [{ day: "YYYY-MM-DD", ...agg }, ...] */ +function byDay<T>( + events: AgentEvent[], + days: number, + initAcc: () => T, + reducer: (acc: T, e: AgentEvent) => void, +): Array<{ day: string } & T> { + const out = new Map<string, T>(); + const now = Date.now(); + const cutoff = now - days * 86_400_000; + // Seed with empty days so the chart has a continuous x-axis. + for (let i = 0; i < days; i++) { + const ms = now - i * 86_400_000; + const day = new Date(ms).toISOString().slice(0, 10); + out.set(day, initAcc()); + } + for (const e of events) { + const tms = new Date(e.ts).getTime(); + if (tms < cutoff) continue; + const day = e.ts.slice(0, 10); + if (!out.has(day)) out.set(day, initAcc()); + reducer(out.get(day)!, e); + } + return Array.from(out.entries()) + .map(([day, agg]) => ({ day, ...(agg as T) })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + +export function registerTrendsRoutes(app: FastifyInstance, events: AgentEvent[]): void { + app.get<{ Querystring: { days?: string } }>("/api/trends/cost", async (req) => { + const days = clamp(parseInt(req.query.days ?? "30", 10) || 30, 1, 90); + const data = byDay<{ cost: number; input: number; output: number }>( + events, + days, + () => ({ cost: 0, input: 0, output: 0 }), + (acc, e) => { + acc.cost += e.details?.cost ?? 0; + if (e.details?.usage) { + acc.input += e.details.usage.input ?? 0; + acc.output += e.details.usage.output ?? 0; + } + }, + ); + return { days, data }; + }); + + app.get<{ Querystring: { days?: string } }>("/api/trends/cache-hit", async (req) => { + const days = clamp(parseInt(req.query.days ?? "30", 10) || 30, 1, 90); + const data = byDay<{ cacheRead: number; cacheCreate: number; totalInput: number }>( + events, + days, + () => ({ cacheRead: 0, cacheCreate: 0, totalInput: 0 }), + (acc, e) => { + const u = e.details?.usage; + if (!u) return; + acc.cacheRead += u.cacheRead ?? 0; + acc.cacheCreate += u.cacheCreate ?? 0; + acc.totalInput += (u.input ?? 0) + (u.cacheRead ?? 0); + }, + ); + return { + days, + data: data.map((d) => ({ + ...d, + // Ratio of tokens served from cache vs total input. + hitRatio: d.totalInput > 0 ? d.cacheRead / d.totalInput : 0, + })), + }; + }); + + app.get<{ Querystring: { days?: string } }>("/api/trends/by-agent", async (req) => { + const days = clamp(parseInt(req.query.days ?? "30", 10) || 30, 1, 90); + // eventCounts per agent per day (wide format for recharts). + const agents = Array.from(new Set(events.map((e) => e.agent))); + const data = byDay<Record<string, number>>( + events, + days, + () => Object.fromEntries(agents.map((a) => [a, 0])), + (acc, e) => { + acc[e.agent] = (acc[e.agent] ?? 0) + 1; + }, + ); + return { days, agents, data }; + }); +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} diff --git a/src/server/routes/yield.ts b/src/server/routes/yield.ts new file mode 100644 index 0000000..117a3dc --- /dev/null +++ b/src/server/routes/yield.ts @@ -0,0 +1,105 @@ +import type { FastifyInstance } from "fastify"; +import { + aggregateProjectYield, + correlateSessionYield, + findProjectGitRoot, + listCommits, +} from "../../git/correlate.js"; +import type { EventStore } from "../../store/sqlite.js"; +import { detectWorkspaceRoot } from "../../util/workspace.js"; + +/** `$/commit` and `$/line` views — pairs sessions and projects with the + * commits that landed during their time window. Returns empty payloads + * when no store is attached or the project isn't a git repo under + * WORKSPACE_ROOT. Read-only. */ +export function registerYieldRoutes( + app: FastifyInstance, + store?: EventStore, +): void { + app.get<{ Params: { id: string } }>( + "/api/sessions/:id/yield", + async (req, reply) => { + const id = decodeURIComponent(req.params.id); + if (!store) return { sessionId: id, ok: false, reason: "no store" }; + const sessions = store.listSessions({ limit: 1, since: undefined }); + const session = store.listSessions({ limit: 5000 }).find( + (s) => s.sessionId === id, + ); + if (!session) { + reply.code(404); + return { sessionId: id, ok: false, reason: "session not found" }; + } + void sessions; + if (!session.project) { + return { + sessionId: id, + ok: false, + reason: "session has no project tag", + }; + } + const repo = findProjectGitRoot(detectWorkspaceRoot(), session.project); + if (!repo) { + return { + sessionId: id, + ok: false, + reason: "project is not a git repo under WORKSPACE_ROOT", + }; + } + const commits = listCommits(repo, { + since: session.firstTs, + until: new Date( + Date.parse(session.lastTs) + 60 * 60 * 1000, + ).toISOString(), + }); + return { + sessionId: id, + ok: true, + project: session.project, + repoPath: repo, + yield: correlateSessionYield(session, commits), + }; + }, + ); + + app.get<{ Params: { name: string } }>( + "/api/projects/:name/yield", + async (req) => { + const name = decodeURIComponent(req.params.name); + if (!store) return { project: name, ok: false, reason: "no store" }; + const repo = findProjectGitRoot(detectWorkspaceRoot(), name); + if (!repo) { + return { + project: name, + ok: false, + reason: "project is not a git repo under WORKSPACE_ROOT", + }; + } + const sessions = store.listSessions({ project: name, limit: 5000 }); + if (sessions.length === 0) { + return { project: name, ok: true, repoPath: repo, yield: emptyYield(name) }; + } + const earliest = sessions + .map((s) => s.firstTs) + .sort()[0] ?? new Date().toISOString(); + const latest = sessions.map((s) => s.lastTs).sort().pop() ?? new Date().toISOString(); + const commits = listCommits(repo, { + since: earliest, + until: new Date(Date.parse(latest) + 60 * 60 * 1000).toISOString(), + }); + return { + project: name, + ok: true, + repoPath: repo, + yield: aggregateProjectYield(name, sessions, commits), + }; + }, + ); +} + +function emptyYield(project: string): { + project: string; + weekly: never[]; + spendWithoutCommit: never[]; +} { + return { project, weekly: [], spendWithoutCommit: [] }; +} diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts new file mode 100644 index 0000000..62f9113 --- /dev/null +++ b/src/server/sse.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { SseBroadcaster } from "./sse.js"; + +function fakeRes(): { + writes: string[]; + ended: boolean; + write: (chunk: string) => void; + end: () => void; + throwOnNextWrite?: boolean; +} { + const obj: ReturnType<typeof fakeRes> = { + writes: [], + ended: false, + write(chunk: string) { + if (this.throwOnNextWrite) { + this.throwOnNextWrite = false; + throw new Error("socket gone"); + } + this.writes.push(chunk); + }, + end() { + this.ended = true; + }, + }; + return obj; +} + +describe("SseBroadcaster", () => { + it("writes a hello frame on attach", () => { + const b = new SseBroadcaster(); + const res = fakeRes(); + b.attach(res as unknown as import("node:http").ServerResponse); + expect(res.writes[0]).toContain("event: hello"); + b.closeAll(); + }); + + it("emits heartbeat frames to all attached clients", () => { + const b = new SseBroadcaster(); + const a = fakeRes(); + const c = fakeRes(); + b.attach(a as unknown as import("node:http").ServerResponse); + b.attach(c as unknown as import("node:http").ServerResponse); + b.pingForTest(); + expect(a.writes.some((w) => w.includes(": heartbeat"))).toBe(true); + expect(c.writes.some((w) => w.includes(": heartbeat"))).toBe(true); + b.closeAll(); + }); + + it("detaches clients whose heartbeat write throws", () => { + const b = new SseBroadcaster(); + const good = fakeRes(); + const dead = fakeRes(); + b.attach(good as unknown as import("node:http").ServerResponse); + b.attach(dead as unknown as import("node:http").ServerResponse); + dead.throwOnNextWrite = true; + b.pingForTest(); + expect(b.clientCount()).toBe(1); + // subsequent broadcast only reaches the good client + b.emitEvent({ + id: "x", + ts: new Date().toISOString(), + agent: "claude-code", + type: "prompt", + sessionId: "s", + riskScore: 0, + }); + expect(good.writes.some((w) => w.includes("event: event"))).toBe(true); + b.closeAll(); + }); + + it("stops the heartbeat timer when the last client detaches", () => { + vi.useFakeTimers(); + try { + const b = new SseBroadcaster(1_000); + const res = fakeRes(); + const id = b.attach(res as unknown as import("node:http").ServerResponse); + expect(b.clientCount()).toBe(1); + b.detach(id); + expect(b.clientCount()).toBe(0); + // Advance past the interval — no new writes should land anywhere. + const writesBefore = res.writes.length; + vi.advanceTimersByTime(5_000); + expect(res.writes.length).toBe(writesBefore); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/server/sse.ts b/src/server/sse.ts new file mode 100644 index 0000000..2c87870 --- /dev/null +++ b/src/server/sse.ts @@ -0,0 +1,119 @@ +import type { ServerResponse } from "node:http"; +import type { AgentEvent, EventDetails } from "../schema.js"; + +/** Heartbeat cadence — below the usual 30–60 s idle-timeout on corporate + * proxies and the default nginx/haproxy `proxy_read_timeout`. Comment + * frames are ignored by EventSource clients but keep the TCP path warm. */ +const HEARTBEAT_MS = 15_000; +const HEARTBEAT_FRAME = ": heartbeat\n\n"; + +/** Minimal SSE broadcaster: one writable raw response per client. + * Drops clients on write failure; no retry, no buffering. + * + * Owns a single shared heartbeat interval — one timer for N clients. + * When a write fails (heartbeat or broadcast), the client is detached + * in one place. Previous design had a per-connection `setInterval` in + * the route handler that only cleared on the socket `close` event, so + * a broadcaster-detached zombie would keep ticking. */ +export class SseBroadcaster { + private clients = new Map<number, ServerResponse>(); + private nextId = 0; + private heartbeat: NodeJS.Timeout | null = null; + private readonly intervalMs: number; + + constructor(intervalMs: number = HEARTBEAT_MS) { + this.intervalMs = intervalMs; + } + + attach(res: ServerResponse): number { + const id = this.nextId++; + this.clients.set(id, res); + try { + res.write(`event: hello\ndata: {"ok":true}\n\n`); + } catch { + this.clients.delete(id); + return id; + } + this.ensureHeartbeat(); + return id; + } + + detach(id: number): void { + this.clients.delete(id); + if (this.clients.size === 0) this.stopHeartbeat(); + } + + emitEvent(event: AgentEvent): void { + this.broadcast("event", event); + } + + emitEnrich(eventId: string, patch: Partial<EventDetails>): void { + this.broadcast("enrich", { eventId, patch }); + } + + emitBudget(status: unknown): void { + this.broadcast("budget", status); + } + + emitAnomaly(sessionId: string, headline: string): void { + this.broadcast("anomaly", { sessionId, headline }); + } + + /** Test hook: force a heartbeat tick without waiting on the timer. */ + pingForTest(): void { + this.tick(); + } + + clientCount(): number { + return this.clients.size; + } + + private broadcast(kind: string, data: unknown): void { + const payload = `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`; + for (const [id, res] of this.clients) { + try { + res.write(payload); + } catch { + this.clients.delete(id); + } + } + if (this.clients.size === 0) this.stopHeartbeat(); + } + + private tick(): void { + for (const [id, res] of this.clients) { + try { + res.write(HEARTBEAT_FRAME); + } catch { + this.clients.delete(id); + } + } + if (this.clients.size === 0) this.stopHeartbeat(); + } + + private ensureHeartbeat(): void { + if (this.heartbeat) return; + this.heartbeat = setInterval(() => this.tick(), this.intervalMs); + // Don't hold the event loop open for a broadcaster with no external + // work — lets `process.exit()` on SIGINT actually exit. + this.heartbeat.unref?.(); + } + + private stopHeartbeat(): void { + if (!this.heartbeat) return; + clearInterval(this.heartbeat); + this.heartbeat = null; + } + + closeAll(): void { + this.stopHeartbeat(); + for (const res of this.clients.values()) { + try { + res.end(); + } catch { + // fine + } + } + this.clients.clear(); + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..e66ced2 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,13 @@ +export { + openStore, + DEFAULT_DB_PATH, + type EventStore, + type SessionSummary, + type ProjectSummary, + type FtsHit, + type ListSessionsOptions, + type PruneResult, + type StoreStats, + type ActivityBucket, +} from "./sqlite.js"; +export { wrapSinkWithStore } from "./wire.js"; diff --git a/src/store/sqlite.test.ts b/src/store/sqlite.test.ts new file mode 100644 index 0000000..0110451 --- /dev/null +++ b/src/store/sqlite.test.ts @@ -0,0 +1,479 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentEvent } from "../schema.js"; +import { openStore, type EventStore } from "./sqlite.js"; + +let dir: string; +let store: EventStore; + +function makeEvent(over: Partial<AgentEvent> = {}): AgentEvent { + return { + id: over.id ?? `evt-${Math.random().toString(36).slice(2, 10)}`, + ts: over.ts ?? new Date().toISOString(), + agent: over.agent ?? "claude-code", + type: over.type ?? "tool_call", + path: over.path, + cmd: over.cmd, + tool: over.tool, + summary: over.summary, + sessionId: over.sessionId, + promptId: over.promptId, + riskScore: over.riskScore ?? 1, + details: over.details, + }; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "agentwatch-store-")); + store = openStore({ dbPath: join(dir, "events.db") }); +}); + +afterEach(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("sqlite store — schema + lifecycle", () => { + it("initializes schema_version to current", () => { + expect(store.stats().schemaVersion).toBeGreaterThanOrEqual(1); + }); + + it("re-opening an existing db is idempotent (CREATE IF NOT EXISTS)", () => { + const first = store.stats(); + store.close(); + store = openStore({ dbPath: join(dir, "events.db") }); + const second = store.stats(); + expect(second.schemaVersion).toBe(first.schemaVersion); + }); +}); + +describe("sqlite store — insert + dedup", () => { + it("stores an event and reads it back", () => { + const e = makeEvent({ + id: "evt-a", + summary: "[auraqu] Edit src/foo.ts", + sessionId: "sess-1", + details: { fullText: "hello world", cost: 0.05, model: "opus-4-6" }, + }); + store.insert(e); + const back = store.getEvent("evt-a"); + expect(back).not.toBeNull(); + expect(back?.summary).toBe("[auraqu] Edit src/foo.ts"); + expect(back?.details?.cost).toBeCloseTo(0.05); + expect(back?.details?.fullText).toBe("hello world"); + }); + + it("dedups on event id (INSERT OR IGNORE)", () => { + const e = makeEvent({ id: "dup", sessionId: "s1" }); + store.insert(e); + store.insert(e); + store.insert(e); + const events = store.listSessionEvents("s1"); + expect(events).toHaveLength(1); + }); + + it("listSessionEvents returns events ordered by ts ascending", () => { + store.insert( + makeEvent({ id: "a", ts: "2026-05-01T10:00:00Z", sessionId: "s2" }), + ); + store.insert( + makeEvent({ id: "b", ts: "2026-05-01T09:00:00Z", sessionId: "s2" }), + ); + store.insert( + makeEvent({ id: "c", ts: "2026-05-01T11:00:00Z", sessionId: "s2" }), + ); + const ids = store.listSessionEvents("s2").map((e) => e.id); + expect(ids).toEqual(["b", "a", "c"]); + }); +}); + +describe("sqlite store — session aggregation trigger", () => { + it("creates a sessions row when the first event of a session arrives", () => { + store.insert( + makeEvent({ + id: "s-evt-1", + sessionId: "sess-x", + ts: "2026-05-01T10:00:00Z", + summary: "[bpi] working", + details: { cost: 0.02 }, + }), + ); + const sessions = store.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + sessionId: "sess-x", + project: "bpi", + eventCount: 1, + }); + expect(sessions[0]?.costUsd).toBeCloseTo(0.02); + }); + + it("accumulates cost + extends the time range as more events arrive", () => { + store.insert( + makeEvent({ + id: "e1", + sessionId: "s", + ts: "2026-05-01T10:00:00Z", + details: { cost: 0.1 }, + }), + ); + store.insert( + makeEvent({ + id: "e2", + sessionId: "s", + ts: "2026-05-01T11:00:00Z", + details: { cost: 0.2 }, + }), + ); + store.insert( + makeEvent({ + id: "e3", + sessionId: "s", + ts: "2026-05-01T09:30:00Z", + details: { cost: 0.05 }, + }), + ); + const [s] = store.listSessions(); + expect(s?.eventCount).toBe(3); + expect(s?.firstTs).toBe("2026-05-01T09:30:00Z"); + expect(s?.lastTs).toBe("2026-05-01T11:00:00Z"); + expect(s?.costUsd).toBeCloseTo(0.35); + }); + + it("filters listSessions by agent + project + since", () => { + store.insert( + makeEvent({ + id: "x1", + sessionId: "sess-a", + agent: "claude-code", + ts: "2026-04-01T10:00:00Z", + summary: "[proj-1] hi", + }), + ); + store.insert( + makeEvent({ + id: "x2", + sessionId: "sess-b", + agent: "codex", + ts: "2026-05-01T10:00:00Z", + summary: "[proj-2] hi", + }), + ); + expect(store.listSessions({ agent: "codex" })).toHaveLength(1); + expect(store.listSessions({ project: "proj-1" })).toHaveLength(1); + expect( + store.listSessions({ since: "2026-04-15T00:00:00Z" }), + ).toHaveLength(1); + }); +}); + +describe("sqlite store — enrich", () => { + it("merges patch into details_json + bumps session cost when cost changes", () => { + store.insert( + makeEvent({ + id: "evt-rich", + sessionId: "rs", + details: { fullText: "before", cost: 0.0 }, + }), + ); + store.enrich("evt-rich", { + toolResult: "stdout output here", + cost: 0.42, + durationMs: 1234, + }); + const back = store.getEvent("evt-rich"); + expect(back?.details?.toolResult).toBe("stdout output here"); + expect(back?.details?.cost).toBeCloseTo(0.42); + expect(back?.details?.fullText).toBe("before"); + const [s] = store.listSessions(); + expect(s?.costUsd).toBeCloseTo(0.42); + }); + + it("enrich on non-existent event id is a no-op", () => { + expect(() => + store.enrich("does-not-exist", { toolResult: "x" }), + ).not.toThrow(); + }); +}); + +describe("sqlite store — listProjects", () => { + it("aggregates per project across agents and sessions", () => { + store.insertMany([ + makeEvent({ + id: "p1", + agent: "claude-code", + sessionId: "s1", + summary: "[auraqu] one", + details: { cost: 0.1 }, + }), + makeEvent({ + id: "p2", + agent: "codex", + sessionId: "s2", + summary: "[auraqu] two", + details: { cost: 0.2 }, + }), + makeEvent({ + id: "p3", + agent: "claude-code", + sessionId: "s3", + summary: "[bpi] three", + }), + ]); + const projects = store.listProjects(); + const auraqu = projects.find((p) => p.name === "auraqu"); + expect(auraqu).toBeDefined(); + expect(auraqu?.eventCount).toBe(2); + expect(auraqu?.byAgent["claude-code"]).toBe(1); + expect(auraqu?.byAgent.codex).toBe(1); + expect(auraqu?.cost).toBeCloseTo(0.3); + expect(auraqu?.sessionIds.sort()).toEqual(["s1", "s2"]); + }); +}); + +describe("sqlite store — fts5 search", () => { + it("matches by full text content with snippet markers", () => { + store.insert( + makeEvent({ + id: "f1", + sessionId: "sf", + details: { + fullText: "the quick brown fox jumps over the lazy dog", + }, + }), + ); + store.insert( + makeEvent({ + id: "f2", + sessionId: "sf", + details: { fullText: "an unrelated message about cats" }, + }), + ); + const hits = store.searchFts("brown fox"); + expect(hits).toHaveLength(1); + expect(hits[0]?.eventId).toBe("f1"); + expect(hits[0]?.snippet).toContain("<<"); + expect(hits[0]?.snippet).toContain(">>"); + }); + + it("sanitizes FTS-special characters so user input doesn't crash", () => { + store.insert( + makeEvent({ + id: "g1", + details: { fullText: "needle in haystack" }, + }), + ); + const hits = store.searchFts('"NEEDLE" AND OR -'); + expect(hits.map((h) => h.eventId)).toContain("g1"); + }); + + it("empty / whitespace-only query returns []", () => { + expect(store.searchFts("")).toEqual([]); + expect(store.searchFts(" ")).toEqual([]); + }); +}); + +describe("sqlite store — tool_call enrichment", () => { + it("enrich updates duration + error fields on a tool event", () => { + store.insert( + makeEvent({ + id: "tc1", + type: "shell_exec", + tool: "Bash", + cmd: "ls -la", + }), + ); + store.enrich("tc1", { durationMs: 99, toolError: true }); + const back = store.getEvent("tc1"); + expect(back?.details?.durationMs).toBe(99); + expect(back?.details?.toolError).toBe(true); + }); +}); + +describe("sqlite store — prune", () => { + it("deletes events older than the cutoff and their session aggregates", () => { + const oldTs = new Date(Date.now() - 100 * 86_400_000).toISOString(); + const recentTs = new Date(Date.now() - 1 * 86_400_000).toISOString(); + store.insert( + makeEvent({ id: "old", sessionId: "old-s", ts: oldTs }), + ); + store.insert( + makeEvent({ id: "new", sessionId: "new-s", ts: recentTs }), + ); + const result = store.prune({ olderThanDays: 90 }); + expect(result.deletedEvents).toBe(1); + expect(store.getEvent("old")).toBeNull(); + expect(store.getEvent("new")).not.toBeNull(); + const sessions = store.listSessions().map((s) => s.sessionId); + expect(sessions).not.toContain("old-s"); + expect(sessions).toContain("new-s"); + }); +}); + +describe("sqlite store — activity rollups (v2)", () => { + it("activityBySession buckets events by category with counts and cost", () => { + store.insertMany([ + makeEvent({ + id: "a1", + sessionId: "act-s", + details: { category: "coding", cost: 0.10 }, + }), + makeEvent({ + id: "a2", + sessionId: "act-s", + details: { category: "coding", cost: 0.05 }, + }), + makeEvent({ + id: "a3", + sessionId: "act-s", + details: { category: "testing", cost: 0.02 }, + }), + makeEvent({ + id: "a4", + sessionId: "other-s", + details: { category: "coding", cost: 0.99 }, + }), + ]); + const buckets = store.activityBySession("act-s"); + const coding = buckets.find((b) => b.category === "coding"); + const testing = buckets.find((b) => b.category === "testing"); + expect(coding?.eventCount).toBe(2); + expect(coding?.costUsd).toBeCloseTo(0.15); + expect(testing?.eventCount).toBe(1); + expect(buckets[0]?.category).toBe("coding"); // sorted by count + }); + + it("activityByProject aggregates across sessions of one project", () => { + store.insertMany([ + makeEvent({ + id: "p1", + sessionId: "s1", + summary: "[bpi] do work", + details: { category: "coding", cost: 0.10 }, + }), + makeEvent({ + id: "p2", + sessionId: "s2", + summary: "[bpi] more work", + details: { category: "coding", cost: 0.20 }, + }), + makeEvent({ + id: "p3", + sessionId: "s3", + summary: "[other] not us", + details: { category: "coding", cost: 0.99 }, + }), + ]); + const buckets = store.activityByProject("bpi"); + expect(buckets).toHaveLength(1); + expect(buckets[0]?.category).toBe("coding"); + expect(buckets[0]?.eventCount).toBe(2); + expect(buckets[0]?.costUsd).toBeCloseTo(0.30); + expect(buckets[0]?.sessionsTouched).toBe(2); + }); + + it("activityByProject sessionsTouched counts distinct sessions per category", () => { + store.insertMany([ + makeEvent({ id: "x1", sessionId: "s1", summary: "[acme] a", details: { category: "coding" } }), + makeEvent({ id: "x2", sessionId: "s1", summary: "[acme] b", details: { category: "coding" } }), + makeEvent({ id: "x3", sessionId: "s2", summary: "[acme] c", details: { category: "coding" } }), + makeEvent({ id: "x4", sessionId: "s3", summary: "[acme] d", details: { category: "testing" } }), + ]); + const buckets = store.activityByProject("acme"); + const coding = buckets.find((b) => b.category === "coding"); + const testing = buckets.find((b) => b.category === "testing"); + expect(coding?.sessionsTouched).toBe(2); + expect(testing?.sessionsTouched).toBe(1); + }); + + it("uses 'chat' as the bucket label for events without a category", () => { + store.insert( + makeEvent({ + id: "u1", + sessionId: "u-sess", + details: { cost: 0.01 }, + }), + ); + const buckets = store.activityBySession("u-sess"); + expect(buckets[0]?.category).toBe("chat"); + }); +}); + +describe("sqlite store — listRecentEvents", () => { + it("returns newest-first by default and respects limit", () => { + const t0 = Date.now(); + for (let i = 0; i < 10; i++) { + store.insert( + makeEvent({ + id: `r-${i}`, + sessionId: "s", + ts: new Date(t0 - i * 1000).toISOString(), + }), + ); + } + const top3 = store.listRecentEvents({ limit: 3 }); + expect(top3.map((e) => e.id)).toEqual(["r-0", "r-1", "r-2"]); + }); + + it("filters by sinceTs", () => { + const baseMs = Date.UTC(2026, 0, 1, 0, 0, 0); + for (let i = 0; i < 5; i++) { + store.insert( + makeEvent({ + id: `t-${i}`, + sessionId: "s", + ts: new Date(baseMs + i * 1000).toISOString(), + }), + ); + } + const since = new Date(baseMs + 2_000).toISOString(); + const tail = store.listRecentEvents({ sinceTs: since, order: "asc" }); + expect(tail.map((e) => e.id)).toEqual(["t-2", "t-3", "t-4"]); + }); + + it("returns asc order when requested", () => { + const t0 = Date.now(); + store.insert(makeEvent({ id: "a-2", sessionId: "s", ts: new Date(t0).toISOString() })); + store.insert(makeEvent({ id: "a-1", sessionId: "s", ts: new Date(t0 - 1000).toISOString() })); + store.insert(makeEvent({ id: "a-3", sessionId: "s", ts: new Date(t0 + 1000).toISOString() })); + const asc = store.listRecentEvents({ order: "asc" }); + expect(asc.map((e) => e.id)).toEqual(["a-1", "a-2", "a-3"]); + }); + + it("clamps oversize limit to 50000", () => { + expect(() => + store.listRecentEvents({ limit: 999_999 }), + ).not.toThrow(); + }); +}); + +describe("sqlite store — bench (10k events)", () => { + it( + "ingests 10k events in under 2s", + () => { + const events: AgentEvent[] = []; + for (let i = 0; i < 10_000; i++) { + events.push( + makeEvent({ + id: `b-${i}`, + sessionId: `bench-s-${i % 50}`, + ts: new Date(Date.now() - i * 1000).toISOString(), + summary: `[bench] turn ${i}`, + details: { + fullText: `event number ${i} body content for fts`, + cost: 0.001, + }, + }), + ); + } + const t0 = performance.now(); + store.insertMany(events); + const elapsed = performance.now() - t0; + expect(elapsed).toBeLessThan(2000); + expect(store.stats().events).toBe(10_000); + }, + 10_000, + ); +}); diff --git a/src/store/sqlite.ts b/src/store/sqlite.ts new file mode 100644 index 0000000..08b6998 --- /dev/null +++ b/src/store/sqlite.ts @@ -0,0 +1,700 @@ +import Database from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { AgentEvent, AgentName, EventDetails, EventType } from "../schema.js"; + +export interface SessionSummary { + sessionId: string; + agent: AgentName; + project: string | null; + firstTs: string; + lastTs: string; + eventCount: number; + costUsd: number; +} + +export interface ProjectSummary { + name: string; + eventCount: number; + byAgent: Record<string, number>; + sessionIds: string[]; + cost: number; + lastTs: string; +} + +export interface FtsHit { + eventId: string; + sessionId: string | null; + agent: AgentName; + ts: string; + type: EventType; + snippet: string; + rank: number; +} + +export interface ListSessionsOptions { + limit?: number; + agent?: AgentName; + project?: string; + since?: string; +} + +export interface ListRecentEventsOptions { + /** ISO timestamp; only events with ts >= sinceTs are returned. */ + sinceTs?: string; + /** Hard cap on rows returned. Defaults to 1000, max 50000. */ + limit?: number; + /** Sort order. "desc" = newest-first (default); "asc" = oldest-first. */ + order?: "asc" | "desc"; +} + +export interface PruneResult { + deletedEvents: number; + deletedSessions: number; +} + +export interface StoreStats { + events: number; + sessions: number; + dbBytes: number; + schemaVersion: number; +} + +export interface ActivityBucket { + category: string; + eventCount: number; + costUsd: number; + /** Distinct sessions in this category. Only populated by activityByProject; + * always 0 (or absent) for activityBySession. */ + sessionsTouched?: number; +} + +export interface EventStore { + insert(event: AgentEvent): void; + insertMany(events: AgentEvent[]): void; + enrich(eventId: string, patch: Partial<EventDetails>): void; + hasEvent(eventId: string): boolean; + getEvent(eventId: string): AgentEvent | null; + listSessionEvents(sessionId: string): AgentEvent[]; + /** Recent events across every session, primarily for ambient passes + * (budget rollups, anomaly histories) that need more than the live + * in-memory ring but less than the full event table. */ + listRecentEvents(opts?: ListRecentEventsOptions): AgentEvent[]; + listSessions(opts?: ListSessionsOptions): SessionSummary[]; + listProjects(): ProjectSummary[]; + searchFts(query: string, opts?: { limit?: number }): FtsHit[]; + /** Per-category event count + cost for a single session. */ + activityBySession(sessionId: string): ActivityBucket[]; + /** Per-category event count + cost across every session in a project. */ + activityByProject(projectName: string): ActivityBucket[]; + prune(opts: { olderThanDays: number }): PruneResult; + stats(): StoreStats; + close(): void; +} + +const SCHEMA_VERSION = 2; + +export const DEFAULT_DB_PATH = join(homedir(), ".agentwatch", "events.db"); + +export function openStore(opts: { dbPath?: string } = {}): EventStore { + const dbPath = opts.dbPath ?? DEFAULT_DB_PATH; + if (dbPath !== ":memory:") { + mkdirSync(dirname(dbPath), { recursive: true }); + } + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("synchronous = NORMAL"); + db.pragma("foreign_keys = ON"); + applyMigrations(db); + return buildStore(db); +} + +function applyMigrations(db: Database.Database): void { + db.exec( + `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`, + ); + const row = db + .prepare("SELECT version FROM schema_version LIMIT 1") + .get() as { version: number } | undefined; + const current = row?.version ?? 0; + if (current < 1) applyV1(db); + if (current < 2) applyV2(db); + db.prepare( + "INSERT OR REPLACE INTO schema_version (version) VALUES (?)", + ).run(SCHEMA_VERSION); +} + +function applyV2(db: Database.Database): void { + // AUR-264: per-event activity category. ALTER TABLE adds the column; + // FTS5 doesn't reference category so the existing triggers stay valid. + // Idempotent — duplicate-column on a re-applied migration is swallowed. + try { + db.exec(`ALTER TABLE events ADD COLUMN category TEXT`); + } catch (err) { + if (!String(err).includes("duplicate column name")) throw err; + } + try { + db.exec(`CREATE INDEX IF NOT EXISTS idx_events_category ON events(category)`); + } catch { + // best effort + } +} + +function applyV1(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + ts TEXT NOT NULL, + agent TEXT NOT NULL, + type TEXT NOT NULL, + path TEXT, + cmd TEXT, + tool TEXT, + summary TEXT, + session_id TEXT, + prompt_id TEXT, + risk_score INTEGER NOT NULL, + project TEXT, + details_json TEXT, + full_text TEXT, + thinking TEXT, + tool_input_json TEXT, + tool_result TEXT, + cost_usd REAL, + model TEXT, + duration_ms INTEGER, + tool_error INTEGER, + sub_agent_id TEXT, + parent_spawn_id TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id); + CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent); + CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts); + CREATE INDEX IF NOT EXISTS idx_events_type ON events(type); + CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(session_id, ts); + CREATE INDEX IF NOT EXISTS idx_events_project ON events(project); + + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + project TEXT, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent); + CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project); + CREATE INDEX IF NOT EXISTS idx_sessions_last_ts ON sessions(last_ts); + + CREATE TABLE IF NOT EXISTS tool_calls ( + event_id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE, + tool TEXT NOT NULL, + duration_ms INTEGER, + error INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_tool_calls_tool ON tool_calls(tool); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + full_text, thinking, tool_result, summary, + content='events', + content_rowid='rowid', + tokenize='porter unicode61' + ); + + CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, full_text, thinking, tool_result, summary) + VALUES (new.rowid, new.full_text, new.thinking, new.tool_result, new.summary); + END; + + CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, full_text, thinking, tool_result, summary) + VALUES ('delete', old.rowid, old.full_text, old.thinking, old.tool_result, old.summary); + END; + + CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, full_text, thinking, tool_result, summary) + VALUES ('delete', old.rowid, old.full_text, old.thinking, old.tool_result, old.summary); + INSERT INTO events_fts(rowid, full_text, thinking, tool_result, summary) + VALUES (new.rowid, new.full_text, new.thinking, new.tool_result, new.summary); + END; + + CREATE TRIGGER IF NOT EXISTS sessions_upsert_on_event_insert + AFTER INSERT ON events + WHEN new.session_id IS NOT NULL BEGIN + INSERT INTO sessions (session_id, agent, project, first_ts, last_ts, event_count, cost_usd) + VALUES (new.session_id, new.agent, new.project, new.ts, new.ts, 1, COALESCE(new.cost_usd, 0)) + ON CONFLICT(session_id) DO UPDATE SET + last_ts = CASE WHEN new.ts > last_ts THEN new.ts ELSE last_ts END, + first_ts = CASE WHEN new.ts < first_ts THEN new.ts ELSE first_ts END, + event_count = event_count + 1, + cost_usd = cost_usd + COALESCE(new.cost_usd, 0), + project = COALESCE(sessions.project, new.project), + updated_at = strftime('%s','now'); + END; + `); +} + +function buildStore(db: Database.Database): EventStore { + const insertStmt = db.prepare(` + INSERT OR IGNORE INTO events ( + id, ts, agent, type, path, cmd, tool, summary, + session_id, prompt_id, risk_score, project, details_json, + full_text, thinking, tool_input_json, tool_result, + cost_usd, model, duration_ms, tool_error, + sub_agent_id, parent_spawn_id, category + ) + VALUES ( + @id, @ts, @agent, @type, @path, @cmd, @tool, @summary, + @session_id, @prompt_id, @risk_score, @project, @details_json, + @full_text, @thinking, @tool_input_json, @tool_result, + @cost_usd, @model, @duration_ms, @tool_error, + @sub_agent_id, @parent_spawn_id, @category + ) + `); + + const insertToolCallStmt = db.prepare(` + INSERT OR REPLACE INTO tool_calls (event_id, tool, duration_ms, error) + VALUES (?, ?, ?, ?) + `); + + const getStmt = db.prepare( + `SELECT * FROM events WHERE id = ?`, + ); + + const hasStmt = db.prepare(`SELECT 1 FROM events WHERE id = ?`); + + const sessionEventsStmt = db.prepare( + `SELECT * FROM events WHERE session_id = ? ORDER BY ts ASC`, + ); + + const insertMany = db.transaction((events: AgentEvent[]) => { + for (const e of events) doInsert(e); + }); + + function doInsert(event: AgentEvent): void { + const d = event.details ?? {}; + const project = extractProject(event); + const params = { + id: event.id, + ts: event.ts, + agent: event.agent, + type: event.type, + path: event.path ?? null, + cmd: event.cmd ?? null, + tool: event.tool ?? null, + summary: event.summary ?? null, + session_id: event.sessionId ?? null, + prompt_id: event.promptId ?? null, + risk_score: event.riskScore, + project, + details_json: d ? JSON.stringify(d) : null, + full_text: d.fullText ?? null, + thinking: d.thinking ?? null, + tool_input_json: d.toolInput ? JSON.stringify(d.toolInput) : null, + tool_result: d.toolResult ?? null, + cost_usd: d.cost ?? null, + model: d.model ?? null, + duration_ms: d.durationMs ?? null, + tool_error: d.toolError == null ? null : d.toolError ? 1 : 0, + sub_agent_id: d.subAgentId ?? null, + parent_spawn_id: d.parentSpawnId ?? null, + category: d.category ?? null, + }; + const info = insertStmt.run(params); + if (info.changes > 0 && event.tool) { + insertToolCallStmt.run( + event.id, + event.tool, + d.durationMs ?? null, + d.toolError ? 1 : 0, + ); + } + } + + const enrichSelectStmt = db.prepare( + `SELECT details_json, cost_usd FROM events WHERE id = ?`, + ); + + const enrichUpdateStmt = db.prepare(` + UPDATE events SET + details_json = @details_json, + full_text = COALESCE(@full_text, full_text), + thinking = COALESCE(@thinking, thinking), + tool_input_json = COALESCE(@tool_input_json, tool_input_json), + tool_result = COALESCE(@tool_result, tool_result), + cost_usd = COALESCE(@cost_usd, cost_usd), + model = COALESCE(@model, model), + duration_ms = COALESCE(@duration_ms, duration_ms), + tool_error = COALESCE(@tool_error, tool_error) + WHERE id = @id + `); + + const sessionCostBumpStmt = db.prepare(` + UPDATE sessions SET cost_usd = cost_usd + ?, updated_at = strftime('%s','now') + WHERE session_id = (SELECT session_id FROM events WHERE id = ?) + `); + + function doEnrich(eventId: string, patch: Partial<EventDetails>): void { + const row = enrichSelectStmt.get(eventId) as + | { details_json: string | null; cost_usd: number | null } + | undefined; + if (!row) return; + const prev = row.details_json ? (JSON.parse(row.details_json) as EventDetails) : {}; + const merged: EventDetails = { ...prev, ...patch }; + enrichUpdateStmt.run({ + id: eventId, + details_json: JSON.stringify(merged), + full_text: patch.fullText ?? null, + thinking: patch.thinking ?? null, + tool_input_json: patch.toolInput ? JSON.stringify(patch.toolInput) : null, + tool_result: patch.toolResult ?? null, + cost_usd: patch.cost ?? null, + model: patch.model ?? null, + duration_ms: patch.durationMs ?? null, + tool_error: + patch.toolError == null ? null : patch.toolError ? 1 : 0, + }); + // Cost arrives via enrich for some adapters (toolResult pairing); reflect + // it in the session aggregate so listSessions stays correct. + if (patch.cost && patch.cost !== row.cost_usd) { + const delta = patch.cost - (row.cost_usd ?? 0); + sessionCostBumpStmt.run(delta, eventId); + } + if (patch.durationMs != null || patch.toolError != null) { + const eventRow = db + .prepare("SELECT tool FROM events WHERE id = ?") + .get(eventId) as { tool: string | null } | undefined; + if (eventRow?.tool) { + insertToolCallStmt.run( + eventId, + eventRow.tool, + merged.durationMs ?? null, + merged.toolError ? 1 : 0, + ); + } + } + } + + return { + insert: doInsert, + insertMany: (events) => insertMany(events), + enrich: doEnrich, + hasEvent(eventId) { + return Boolean(hasStmt.get(eventId)); + }, + getEvent(eventId) { + const row = getStmt.get(eventId) as RawEventRow | undefined; + return row ? rowToEvent(row) : null; + }, + listSessionEvents(sessionId) { + const rows = sessionEventsStmt.all(sessionId) as RawEventRow[]; + return rows.map(rowToEvent); + }, + listRecentEvents(opts = {}) { + const limit = clamp(opts.limit ?? 1000, 1, 50_000); + const order = opts.order === "asc" ? "ASC" : "DESC"; + const where: string[] = []; + const params: unknown[] = []; + if (opts.sinceTs) { + where.push("ts >= ?"); + params.push(opts.sinceTs); + } + const sql = ` + SELECT * FROM events + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY ts ${order} + LIMIT ? + `; + const rows = db.prepare(sql).all(...params, limit) as RawEventRow[]; + return rows.map(rowToEvent); + }, + listSessions(opts = {}) { + const limit = clamp(opts.limit ?? 200, 1, 5000); + const where: string[] = []; + const params: unknown[] = []; + if (opts.agent) { + where.push("agent = ?"); + params.push(opts.agent); + } + if (opts.project) { + where.push("project = ?"); + params.push(opts.project); + } + if (opts.since) { + where.push("last_ts >= ?"); + params.push(opts.since); + } + const sql = ` + SELECT session_id, agent, project, first_ts, last_ts, event_count, cost_usd + FROM sessions + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY last_ts DESC + LIMIT ? + `; + const rows = db.prepare(sql).all(...params, limit) as Array<{ + session_id: string; + agent: AgentName; + project: string | null; + first_ts: string; + last_ts: string; + event_count: number; + cost_usd: number; + }>; + return rows.map((r) => ({ + sessionId: r.session_id, + agent: r.agent, + project: r.project, + firstTs: r.first_ts, + lastTs: r.last_ts, + eventCount: r.event_count, + costUsd: r.cost_usd, + })); + }, + listProjects() { + const rows = db + .prepare( + `SELECT project, agent, COUNT(*) AS event_count, MAX(ts) AS last_ts, + COALESCE(SUM(cost_usd), 0) AS cost_total, session_id + FROM events + WHERE project IS NOT NULL + GROUP BY project, agent, session_id`, + ) + .all() as Array<{ + project: string; + agent: AgentName; + event_count: number; + last_ts: string; + cost_total: number; + session_id: string | null; + }>; + const byProject = new Map<string, ProjectSummary>(); + for (const r of rows) { + let p = byProject.get(r.project); + if (!p) { + p = { + name: r.project, + eventCount: 0, + byAgent: {}, + sessionIds: [], + cost: 0, + lastTs: r.last_ts, + }; + byProject.set(r.project, p); + } + p.eventCount += r.event_count; + p.byAgent[r.agent] = (p.byAgent[r.agent] ?? 0) + r.event_count; + if (r.session_id && !p.sessionIds.includes(r.session_id)) { + p.sessionIds.push(r.session_id); + } + p.cost += r.cost_total ?? 0; + if (r.last_ts > p.lastTs) p.lastTs = r.last_ts; + } + return Array.from(byProject.values()).sort((a, b) => + a.lastTs < b.lastTs ? 1 : -1, + ); + }, + searchFts(query, opts = {}) { + const limit = clamp(opts.limit ?? 100, 1, 500); + const safe = sanitizeFtsQuery(query); + if (!safe) return []; + const rows = db + .prepare( + `SELECT e.id AS id, e.session_id AS session_id, e.agent AS agent, + e.ts AS ts, e.type AS type, fts.rank AS rank, + snippet(events_fts, -1, '<<', '>>', '…', 16) AS snip + FROM events_fts AS fts + JOIN events AS e ON e.rowid = fts.rowid + WHERE events_fts MATCH ? + ORDER BY rank + LIMIT ?`, + ) + .all(safe, limit) as Array<{ + id: string; + session_id: string | null; + agent: AgentName; + ts: string; + type: EventType; + rank: number; + snip: string; + }>; + return rows.map((r) => ({ + eventId: r.id, + sessionId: r.session_id, + agent: r.agent, + ts: r.ts, + type: r.type, + snippet: r.snip, + rank: r.rank, + })); + }, + activityBySession(sessionId) { + const rows = db + .prepare( + `SELECT COALESCE(category, 'chat') AS category, + COUNT(*) AS event_count, + COALESCE(SUM(cost_usd), 0) AS cost_total + FROM events + WHERE session_id = ? + GROUP BY COALESCE(category, 'chat')`, + ) + .all(sessionId) as Array<{ + category: string; + event_count: number; + cost_total: number; + }>; + return rows + .map((r) => ({ + category: r.category, + eventCount: r.event_count, + costUsd: r.cost_total, + })) + .sort((a, b) => b.eventCount - a.eventCount); + }, + activityByProject(projectName) { + const rows = db + .prepare( + `SELECT COALESCE(category, 'chat') AS category, + COUNT(*) AS event_count, + COALESCE(SUM(cost_usd), 0) AS cost_total, + COUNT(DISTINCT session_id) AS sessions_touched + FROM events + WHERE project = ? + GROUP BY COALESCE(category, 'chat')`, + ) + .all(projectName) as Array<{ + category: string; + event_count: number; + cost_total: number; + sessions_touched: number; + }>; + return rows + .map((r) => ({ + category: r.category, + eventCount: r.event_count, + costUsd: r.cost_total, + sessionsTouched: r.sessions_touched, + })) + .sort((a, b) => b.eventCount - a.eventCount); + }, + prune({ olderThanDays }) { + const cutoffMs = Date.now() - olderThanDays * 86_400_000; + const cutoff = new Date(cutoffMs).toISOString(); + const events = db + .prepare(`DELETE FROM events WHERE ts < ?`) + .run(cutoff); + const sessions = db + .prepare(`DELETE FROM sessions WHERE last_ts < ?`) + .run(cutoff); + // VACUUM is expensive; we use incremental_vacuum via auto_vacuum if set, + // else a one-shot only for non-tiny prunes. + if (events.changes > 1000) { + try { + db.exec("VACUUM"); + } catch { + // VACUUM in WAL mode may transiently fail under contention; not fatal. + } + } + return { + deletedEvents: Number(events.changes), + deletedSessions: Number(sessions.changes), + }; + }, + stats() { + const eventCount = ( + db.prepare("SELECT COUNT(*) AS c FROM events").get() as { c: number } + ).c; + const sessionCount = ( + db.prepare("SELECT COUNT(*) AS c FROM sessions").get() as { + c: number; + } + ).c; + const pages = ( + db.prepare("PRAGMA page_count").get() as { page_count: number } + ).page_count; + const pageSize = ( + db.prepare("PRAGMA page_size").get() as { page_size: number } + ).page_size; + const versionRow = db + .prepare("SELECT version FROM schema_version LIMIT 1") + .get() as { version: number } | undefined; + return { + events: eventCount, + sessions: sessionCount, + dbBytes: pages * pageSize, + schemaVersion: versionRow?.version ?? 0, + }; + }, + close() { + db.close(); + }, + }; +} + +interface RawEventRow { + id: string; + ts: string; + agent: AgentName; + type: EventType; + path: string | null; + cmd: string | null; + tool: string | null; + summary: string | null; + session_id: string | null; + prompt_id: string | null; + risk_score: number; + project: string | null; + details_json: string | null; +} + +function rowToEvent(row: RawEventRow): AgentEvent { + const details = row.details_json + ? (JSON.parse(row.details_json) as EventDetails) + : undefined; + return { + id: row.id, + ts: row.ts, + agent: row.agent, + type: row.type, + path: row.path ?? undefined, + cmd: row.cmd ?? undefined, + tool: row.tool ?? undefined, + summary: row.summary ?? undefined, + sessionId: row.session_id ?? undefined, + promptId: row.prompt_id ?? undefined, + riskScore: row.risk_score, + details, + }; +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} + +function extractProject(e: AgentEvent): string | null { + const m = (e.summary ?? "").match(/^\[([^\]/ ]+)/); + return m ? (m[1] ?? null) : null; +} + +// FTS5 reserved characters or stray boolean operators crash the parser. +// Strip everything but word chars + spaces, drop FTS5 keywords that the +// user usually typed incidentally, then OR the remaining tokens for +// search-as-you-type recall. Empty result means no query. +const FTS_KEYWORDS = new Set(["AND", "OR", "NOT", "NEAR"]); + +function sanitizeFtsQuery(q: string): string { + const cleaned = q + .replace(/[^\p{L}\p{N}\s_-]/gu, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) return ""; + const tokens = cleaned + .split(" ") + .filter((t) => t.length > 0 && !FTS_KEYWORDS.has(t.toUpperCase())) + .map((t) => `"${t}"`); + if (tokens.length === 0) return ""; + return tokens.join(" OR "); +} diff --git a/src/store/wire.ts b/src/store/wire.ts new file mode 100644 index 0000000..781bfb0 --- /dev/null +++ b/src/store/wire.ts @@ -0,0 +1,45 @@ +import type { EventDetails, EventSink } from "../schema.js"; +import type { EventStore } from "./sqlite.js"; + +/** Wraps an existing EventSink so every emit/enrich is mirrored into the + * SQLite store. The store is the persistent source of truth; the inner + * sink continues to drive the in-memory TUI/SSE pipeline. + * + * Failures in the store path are logged once per failure-mode and never + * propagated — observability must not crash the agent runtime when, e.g., + * the disk is full or the WAL is locked. */ +export function wrapSinkWithStore( + inner: EventSink, + store: EventStore, +): EventSink { + let warnedInsert = false; + let warnedEnrich = false; + return { + emit: (event) => { + try { + store.insert(event); + } catch (err) { + if (!warnedInsert) { + warnedInsert = true; + process.stderr.write( + `[agentwatch] store.insert error (further occurrences suppressed): ${String(err)}\n`, + ); + } + } + inner.emit(event); + }, + enrich: (eventId: string, patch: Partial<EventDetails>) => { + try { + store.enrich(eventId, patch); + } catch (err) { + if (!warnedEnrich) { + warnedEnrich = true; + process.stderr.write( + `[agentwatch] store.enrich error (further occurrences suppressed): ${String(err)}\n`, + ); + } + } + inner.enrich(eventId, patch); + }, + }; +} diff --git a/src/ui/AgentPanel.tsx b/src/ui/AgentPanel.tsx index 99bf96a..2f4be19 100644 --- a/src/ui/AgentPanel.tsx +++ b/src/ui/AgentPanel.tsx @@ -12,15 +12,26 @@ export function AgentPanel({ agents, events }: Props) { <Box flexDirection="column" borderStyle="single" paddingX={1}> <Text bold>Agents</Text> {agents.map((a) => { - const count = events.filter((e) => e.agent === a.name).length; - const last = events.find((e) => e.agent === a.name); + const forAgent = events.filter((e) => e.agent === a.name); + const count = forAgent.length; + const last = forAgent[0]; + const dotColor = !a.present + ? "gray" + : a.instrumented + ? "green" + : "yellow"; + const statusLabel = !a.present + ? "not detected" + : a.instrumented + ? "installed" + : "detected (events TBD)"; return ( <Box key={a.name} flexDirection="column" marginTop={1}> - <Text color={a.present ? "green" : "gray"}> + <Text color={dotColor}> {a.present ? "●" : "○"} {a.label} </Text> - <Text dimColor> {a.present ? "installed" : "not detected"}</Text> - {a.present && ( + <Text dimColor> {statusLabel}</Text> + {a.present && a.instrumented && ( <Text dimColor> {" "}events: {count} {last ? `, last ${last.ts.slice(11, 19)}` : ""} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 27bbf61..64551d3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,92 +1,376 @@ -import { useEffect, useReducer, useState } from "react"; -import { Box, Text, useApp, useInput } from "ink"; -import type { AgentEvent, AgentName } from "../schema.js"; +import { useEffect, useMemo, useReducer, useState } from "react"; +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import type { AgentEvent, AgentName, EventDetails, EventSink } from "../schema.js"; import { Timeline } from "./Timeline.js"; import { AgentPanel } from "./AgentPanel.js"; import { Header } from "./Header.js"; -import { PermissionView } from "./PermissionView.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { computeBudgetStatus } from "../util/budgets.js"; +import { emitEventSpan, initOtel, otelEnabled } from "../util/otel.js"; +import { watchTriggers } from "../util/triggers.js"; +import { + detectStuckLoop, + scoreEvent, + summarizeBySession, + type AnomalyFlag, +} from "../util/anomaly.js"; +import { notify, shouldNotify } from "../util/notifier.js"; import { detectAgents } from "../adapters/detect.js"; -import { startClaudeAdapter } from "../adapters/claude-code.js"; -import { startOpenClawAdapter } from "../adapters/openclaw.js"; -import { startCursorAdapter } from "../adapters/cursor.js"; -import { startFsAdapter } from "../adapters/fs-watcher.js"; +import { + startAllAdapters, + stopAllAdapters, +} from "../adapters/registry.js"; import { detectWorkspaceRoot } from "../util/workspace.js"; -import { readClaudePermissions } from "../util/claude-permissions.js"; - -const MAX_EVENTS = 500; - -type State = { - events: AgentEvent[]; - filterAgent: AgentName | null; - showAgents: boolean; - showPermissions: boolean; - paused: boolean; -}; - -type Action = - | { type: "event"; event: AgentEvent } - | { type: "toggle-agents" } - | { type: "toggle-permissions" } - | { type: "cycle-filter"; agents: AgentName[] } - | { type: "toggle-pause" } - | { type: "clear" }; - -function reducer(state: State, action: Action): State { - switch (action.type) { - case "event": { - if (state.paused) return state; - const events = [action.event, ...state.events].slice(0, MAX_EVENTS); - return { ...state, events }; - } - case "toggle-agents": - return { ...state, showAgents: !state.showAgents }; - case "toggle-permissions": - return { ...state, showPermissions: !state.showPermissions }; - case "cycle-filter": { - const idx = state.filterAgent - ? action.agents.indexOf(state.filterAgent) - : -1; - const next = - idx + 1 >= action.agents.length ? null : action.agents[idx + 1]; - return { ...state, filterAgent: next ?? null }; - } - case "toggle-pause": - return { ...state, paused: !state.paused }; - case "clear": - return { ...state, events: [] }; - } -} +import { initialState, matchesQuery, reducer } from "./state.js"; +import { startServer, type ServerHandle, addEventToServer } from "../server/index.js"; +import { openUrl } from "../util/open-url.js"; +import { onShutdown } from "../util/shutdown.js"; +import { openStore, wrapSinkWithStore, type EventStore } from "../store/index.js"; +import { withClaudeHookDedup } from "../adapters/hooks-dedup.js"; +import { withClassifier } from "../classify/index.js"; +/** + * agentwatch TUI — live log tail. + * + * Since v0.0.4 the TUI only shows the live event stream. All the + * former "drill-down" views (sessions list, event detail, tokens, + * compaction, call graph, permissions, scheduled, search, help) moved + * to the web UI at `http://127.0.0.1:3456`. Press `w` to open it. + * + * The TUI still runs the adapters, reducer, anomaly scoring, budget + * checks, and desktop notifications — everything "ambient." What moved + * is the interactive navigation; browsers are just better at that. + */ export function App() { const { exit } = useApp(); const [workspace] = useState(detectWorkspaceRoot()); const [agents] = useState(detectAgents()); - const [permissions] = useState(() => readClaudePermissions(workspace)); - const [state, dispatch] = useReducer(reducer, { - events: [], - filterAgent: null, - showAgents: true, - showPermissions: false, - paused: false, - }); + const { stdout } = useStdout(); + const [state, dispatch] = useReducer(reducer, undefined, initialState); + const [server, setServer] = useState<ServerHandle | null>(null); + const [store, setStore] = useState<EventStore | null>(null); + const noWeb = process.argv.includes("--no-web"); + + // Persistent SQLite store — opened once, drains on shutdown. Failure to + // open (e.g. read-only home dir) leaves store=null; the rest of the TUI + // continues to work against the in-memory ring buffer. + useEffect(() => { + let s: EventStore | null = null; + let unregister: (() => void) | null = null; + try { + s = openStore(); + setStore(s); + unregister = onShutdown(() => s?.close()); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[agentwatch] event store unavailable: ${String(err)}`); + } + return () => { + unregister?.(); + if (s) { + try { s.close(); } catch { /* already closed */ } + } + }; + }, []); + // Server lifecycle — started once, lives as long as the TUI does. useEffect(() => { - const onEvent = (e: AgentEvent) => - dispatch({ type: "event", event: e }); - const stopClaude = startClaudeAdapter(onEvent); - const stopOpenClaw = startOpenClawAdapter(onEvent); - const stopCursor = startCursorAdapter(workspace, onEvent); - const stopFs = startFsAdapter(workspace, onEvent); + if (noWeb) return; + if (!store) return; // wait for the store before starting the server + const events: AgentEvent[] = []; + const port = Number( + findFlag("--port") ?? process.env.AGENTWATCH_PORT ?? 3456, + ); + const host = findFlag("--host") ?? process.env.AGENTWATCH_HOST ?? "127.0.0.1"; + let handle: ServerHandle | null = null; + let cancelled = false; + let unregister: (() => void) | null = null; + // hookSink is wired in the second useEffect once adapters are up. + // Until then, hook POSTs return 404 — that's fine; the hook curls + // exit 0 on failure and never block Claude. + startServer({ host, port, events, store }) + .then((h) => { + if (cancelled) { + void h.stop(); + return; + } + handle = h; + setServer(h); + unregister = onShutdown(() => h.stop()); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(`[agentwatch] web server failed to start: ${String(err)}`); + }); + return () => { + cancelled = true; + unregister?.(); + if (handle) void handle.stop(); + }; + }, [store]); + + useEffect(() => { + const stopTriggersWatch = watchTriggers(); + if (otelEnabled()) void initOtel(); + const launchedAt = Date.now(); + // Seed the live tail from the store so the timeline isn't empty until + // adapters re-emit their JSONL backfill. Dedup at the wrapSinkWithStore + // boundary handles any id collisions when adapters do re-emit. + if (store) { + try { + const seed = store.listRecentEvents({ limit: 500, order: "desc" }); + if (seed.length > 0) dispatch({ type: "events-batch", events: seed }); + } catch { + // store may be unavailable; live tail will fill from adapters + } + } + // Coalesce incoming events into a single dispatch per frame. + let pending: AgentEvent[] = []; + let flushScheduled = false; + const FLUSH_MS = 16; + const flush = (): void => { + flushScheduled = false; + if (pending.length === 0) return; + const batch = pending; + pending = []; + if (batch.length === 1) { + dispatch({ type: "event", event: batch[0]! }); + } else { + dispatch({ type: "events-batch", events: batch }); + } + }; + const sink: EventSink = { + emit: (e: AgentEvent) => { + pending.push(e); + if (!flushScheduled) { + flushScheduled = true; + setTimeout(flush, FLUSH_MS); + } + emitEventSpan(e); + if (server) { + // Per-agent cap so one verbose agent (claude-code emits ~50k + // events from a few days of history) can't evict everyone + // else. Oldest-first storage inside each bucket; routes + // merge across buckets on read. + addEventToServer(server, e); + server.broadcaster.emitEvent(e); + } + const eventMs = new Date(e.ts).getTime(); + if (eventMs < launchedAt) return; + const alert = shouldNotify(e); + if (alert) notify(alert.title, alert.body); + }, + enrich: (eventId: string, patch: Partial<EventDetails>) => { + dispatch({ type: "enrich", eventId, patch }); + if (server) { + for (const bucket of server.byAgent.values()) { + const target = bucket.find((x) => x.id === eventId); + if (target) { + target.details = { ...(target.details ?? {}), ...patch }; + break; + } + } + server.broadcaster.emitEnrich(eventId, patch); + } + }, + }; + const persistSink = store ? wrapSinkWithStore(sink, store) : sink; + const classifiedSink = withClassifier(persistSink); + const finalSink = withClaudeHookDedup(classifiedSink); + if (server) { + // Hooks route forwards Claude hook curls into the same pipeline + // as JSONL events. The dedup wrapper at the head of the chain + // distinguishes hook events (bypass) from JSONL events (check). + server.setHookSink(finalSink); + } + const adapters = startAllAdapters(finalSink, workspace); + const unregisterShutdown = onShutdown(() => { + flush(); + stopAllAdapters(adapters); + stopTriggersWatch(); + }); return () => { - stopClaude(); - stopOpenClaw(); - stopCursor(); - stopFs(); + unregisterShutdown(); + flush(); + stopAllAdapters(adapters); + stopTriggersWatch(); }; - }, [workspace]); + }, [workspace, server, store]); + + const agentFiltered = state.filterAgent + ? state.events.filter((e) => e.agent === state.filterAgent) + : state.events; + const filtered = state.searchQuery + ? agentFiltered.filter((e) => matchesQuery(e, state.searchQuery)) + : agentFiltered; + + // Live-tail ring drives the timeline view. The expensive ambient passes + // (budget rollups, anomaly histories, sub-agent child counts) read from + // the store so they see the full retention window, not just the last + // ~500 events held in React state. eventsRef is the recompute trigger — + // any new event grows the ring and busts these memos. + const eventsRef = state.events; + + const budgetStatus = useMemo(() => { + if (!store) return computeBudgetStatus(eventsRef); + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const since = todayStart.toISOString(); + // Per-session caps are checked against per-session totals — capped at 30 days + // so a long-running session that started yesterday is still seen. Day rollup + // filters by ts inside computeBudgetStatus. + const monthAgo = new Date(Date.now() - 30 * 86_400_000).toISOString(); + const events = store.listRecentEvents({ + sinceTs: monthAgo < since ? monthAgo : since, + limit: 50_000, + order: "asc", + }); + return computeBudgetStatus(events); + }, [eventsRef, store]); + + const anomalies = useMemo(() => { + const source = store + ? store.listRecentEvents({ limit: 5_000, order: "desc" }) + : eventsRef; + const out = new Map<string, AnomalyFlag[]>(); + const sliceEnd = Math.min(40, source.length); + const historyByAgent = new Map<string, AgentEvent[]>(); + for (const e of source) { + let arr = historyByAgent.get(e.agent); + if (!arr) { + arr = []; + historyByAgent.set(e.agent, arr); + } + arr.push(e); + } + for (let i = 0; i < sliceEnd; i++) { + const ev = source[i]!; + const agentHistory = historyByAgent.get(ev.agent) ?? []; + const pos = agentHistory.indexOf(ev); + const history = pos >= 0 ? agentHistory.slice(pos + 1) : agentHistory; + if (history.length === 0) continue; + const flags = scoreEvent(ev, history); + if (flags.length > 0) out.set(ev.id, flags); + } + const stuckLoop = detectStuckLoop(source.slice(0, 20).reverse()); + if (stuckLoop) { + const first = source[0]; + if (first) { + const prev = out.get(first.id) ?? []; + const label = + stuckLoop.period === 1 + ? `same tool fired ${stuckLoop.count}× in a row` + : `period-${stuckLoop.period} loop (${stuckLoop.count} cycles): ${stuckLoop.pattern}`; + out.set(first.id, [ + ...prev, + { + kind: "stuck-loop", + message: `stuck loop: ${label}`, + magnitude: stuckLoop.count, + sessionId: first.sessionId, + }, + ]); + } + } + return out; + }, [eventsRef, store]); + + // Budget-breach notifications (once per distinct breach). + const budgetBreachKey = [ + budgetStatus.breachedSession ?? "", + budgetStatus.dayBreach ? "day" : "", + ].join("|"); + useEffect(() => { + if (!budgetStatus.breachedSession && !budgetStatus.dayBreach) return; + if (budgetStatus.breachedSession && budgetStatus.perSessionUsd != null) { + notify( + "⚠ agentwatch — session budget breached", + `session ${budgetStatus.breachedSession.slice(0, 8)} $${budgetStatus.sessionCost.toFixed(4)} > cap $${budgetStatus.perSessionUsd.toFixed(2)}`, + ); + } + if (budgetStatus.dayBreach && budgetStatus.perDayUsd != null) { + notify( + "⚠ agentwatch — daily budget breached", + `today $${budgetStatus.dayCost.toFixed(4)} > cap $${budgetStatus.perDayUsd.toFixed(2)}`, + ); + } + }, [budgetBreachKey]); + + const sessionSummaries = useMemo(() => summarizeBySession(anomalies), [anomalies]); + const anomalyKey = sessionSummaries.map((s) => `${s.sessionId}:${s.headline}`).join("|"); + const bannerSuppressed = state.anomalyDismissKey === anomalyKey; + + useEffect(() => { + const toNotify: string[] = []; + for (const [id, flags] of anomalies) { + if (state.anomalyNotified.has(id)) continue; + for (const f of flags) { + notify(`⚠ agentwatch anomaly`, `${f.kind}: ${f.message}`); + toNotify.push(id); + break; + } + } + if (toNotify.length > 0) { + dispatch({ type: "anomaly-mark-notified", ids: toNotify }); + } + }, [anomalyKey]); + + const childCountByAgentId = useMemo(() => { + const source = store + ? store.listRecentEvents({ limit: 10_000, order: "desc" }) + : eventsRef; + const m = new Map<string, number>(); + for (const e of source) { + if (e.sessionId?.startsWith("agent-")) { + const aid = e.sessionId.slice("agent-".length); + m.set(aid, (m.get(aid) ?? 0) + 1); + } + } + return m; + }, [eventsRef, store]); + + const cols = stdout.columns || 120; + const rows = stdout.rows || 30; + const tooNarrow = cols < 60; + const tooShort = rows < 12; useInput((input, key) => { - if (input === "q" || (key.ctrl && input === "c")) exit(); + if (key.ctrl && input === "c") { + // `exit()` unmounts ink, which triggers our useEffect cleanup + // (stopAllAdapters etc). waitUntilExit().finally in index.tsx + // drains the global shutdown hooks before process.exit. + exit(); + return; + } + if (state.searchOpen) { + if (key.escape) { + dispatch({ type: "close-search" }); + return; + } + if (key.return) { + dispatch({ type: "confirm-search" }); + return; + } + if (key.backspace || key.delete) { + dispatch({ type: "search-backspace" }); + return; + } + if (input && !key.ctrl && !key.meta) { + dispatch({ type: "search-input", char: input }); + } + return; + } + if (input === "q") { + exit(); + return; + } + if (input === "w" && server) { + openUrl(server.url); + dispatch({ type: "flash", text: `→ opening ${server.url}` }); + setTimeout(() => dispatch({ type: "flash-clear" }), 2000); + return; + } + if (input === "/") dispatch({ type: "open-search" }); if (input === "a") dispatch({ type: "toggle-agents" }); if (input === "f") { const presentAgents = agents.filter((a) => a.present).map((a) => a.name); @@ -96,13 +380,32 @@ export function App() { dispatch({ type: "cycle-filter", agents: pool }); } if (input === " ") dispatch({ type: "toggle-pause" }); - if (input === "p") dispatch({ type: "toggle-permissions" }); if (input === "c") dispatch({ type: "clear" }); + if (input === "D" && anomalyKey) { + dispatch({ type: "anomaly-dismiss", key: anomalyKey }); + } + if (key.downArrow || input === "j") + dispatch({ type: "move", delta: 1, max: filtered.length }); + if (key.upArrow || input === "k") + dispatch({ type: "move", delta: -1, max: filtered.length }); }); - const filtered = state.filterAgent - ? state.events.filter((e) => e.agent === state.filterAgent) - : state.events; + if (tooNarrow || tooShort) { + return ( + <Box flexDirection="column" padding={1}> + <Text color="yellow" bold> + Terminal too small for the agentwatch TUI + </Text> + <Text>Detected: {cols} cols × {rows} rows</Text> + <Text>Minimum: 60 cols × 12 rows</Text> + <Text> </Text> + <Text dimColor> + Resize the window and restart — or just open the web UI at{" "} + {server?.url ?? "http://127.0.0.1:3456"}. + </Text> + </Box> + ); + } return ( <Box flexDirection="column"> @@ -111,26 +414,63 @@ export function App() { eventCount={state.events.length} filter={state.filterAgent} paused={state.paused} + budget={budgetStatus} + anomalies={bannerSuppressed ? undefined : anomalies} + sessionAnomalies={bannerSuppressed ? [] : sessionSummaries} + webUrl={server?.url} /> - {state.showPermissions ? ( - <PermissionView permissions={permissions} /> - ) : ( - <Box flexDirection="row"> - <Box flexGrow={1} flexDirection="column"> - <Timeline events={filtered} /> - </Box> - {state.showAgents && ( - <Box width={32} marginLeft={1}> - <AgentPanel agents={agents} events={state.events} /> - </Box> - )} + <Breadcrumb + projectFilter={state.projectFilter} + sessionFilter={state.sessionFilter} + sessionsForProject={null} + subAgentScope={state.subAgentScope} + agentFilter={state.filterAgent} + searchQuery={state.searchQuery} + view="timeline" + /> + <Box flexDirection="row"> + <Box flexGrow={1} flexDirection="column"> + <Timeline + events={filtered} + selectedIdx={state.selectedIdx} + childCountByAgentId={childCountByAgentId} + anomalies={anomalies} + /> </Box> - )} - <Box marginTop={1}> + {state.showAgents && ( + <Box width={32} marginLeft={1}> + <AgentPanel agents={agents} events={state.events} /> + </Box> + )} + </Box> + <Box marginTop={1} flexDirection="column"> + {state.flashMessage && ( + <Text color={state.flashMessage.startsWith("✓") ? "green" : "red"}> + {state.flashMessage} + </Text> + )} + {(state.searchOpen || state.searchQuery) && ( + <Text> + <Text color="yellow">/ </Text> + <Text>{state.searchQuery}</Text> + {state.searchOpen && <Text color="yellow">▌</Text>} + {state.searchQuery && <Text dimColor> matches: {filtered.length}</Text>} + </Text> + )} <Text dimColor> - [q] quit [a] agents [f] filter [p] permissions [space] {state.paused ? "resume" : "pause"} [c] clear + {state.searchOpen + ? "[type to filter] [enter] confirm [esc] clear" + : "[q] quit [w] open web UI [/] filter [a] agents panel [f] cycle agent filter [space] pause [c] clear"} </Text> + <Text dimColor>All other views (projects, sessions, search, tokens, graph, settings, trends, replay, diffs) → web UI.</Text> </Box> </Box> ); } + +function findFlag(name: string): string | undefined { + const idx = process.argv.indexOf(name); + if (idx === -1) return undefined; + const val = process.argv[idx + 1]; + return val && !val.startsWith("--") ? val : undefined; +} diff --git a/src/ui/Breadcrumb.tsx b/src/ui/Breadcrumb.tsx new file mode 100644 index 0000000..4b6f9d2 --- /dev/null +++ b/src/ui/Breadcrumb.tsx @@ -0,0 +1,103 @@ +import { Box, Text } from "ink"; +import type { AgentName } from "../schema.js"; + +interface Props { + projectFilter: string | null; + sessionFilter: string | null; + sessionsForProject: string | null; + subAgentScope: string | null; + agentFilter: AgentName | null; + searchQuery: string; + view: + | "timeline" + | "projects" + | "sessions" + | "permissions" + | "detail" + | "help"; +} + +export function Breadcrumb(props: Props) { + const parts: JSX.Element[] = [ + <Text key="root" color="cyan" bold> + agentwatch + </Text>, + ]; + + const push = (el: JSX.Element) => { + parts.push( + <Text key={`sep-${parts.length}`} dimColor> + {" · "} + </Text>, + ); + parts.push(el); + }; + + if (props.view === "help") push(<Text key="v">help</Text>); + if (props.view === "projects") push(<Text key="v">projects</Text>); + if (props.view === "permissions") push(<Text key="v">permissions</Text>); + + if (props.sessionsForProject) + push( + <Text key="proj-list"> + <Text bold>{props.sessionsForProject}</Text> + <Text dimColor> (sessions)</Text> + </Text>, + ); + + if (props.projectFilter && !props.sessionsForProject) + push( + <Text key="proj"> + <Text dimColor>project </Text> + <Text bold color="yellow"> + {props.projectFilter} + </Text> + </Text>, + ); + + if (props.sessionFilter) + push( + <Text key="sess"> + <Text dimColor>session </Text> + <Text bold color="yellow"> + {props.sessionFilter.slice(0, 16)} + </Text> + </Text>, + ); + + if (props.subAgentScope) + push( + <Text key="sub"> + <Text dimColor>sub </Text> + <Text bold color="yellow"> + {props.subAgentScope.slice(0, 12)} + </Text> + </Text>, + ); + + if (props.agentFilter) + push( + <Text key="agent"> + <Text dimColor>agent </Text> + <Text bold color="yellow"> + {props.agentFilter} + </Text> + </Text>, + ); + + if (props.searchQuery) + push( + <Text key="q"> + <Text dimColor>search </Text> + <Text bold color="yellow"> + "{props.searchQuery}" + </Text> + </Text>, + ); + + return ( + <Box> + <Text>{parts}</Text> + </Box> + ); +} diff --git a/src/ui/CallGraphView.tsx b/src/ui/CallGraphView.tsx new file mode 100644 index 0000000..8ececc1 --- /dev/null +++ b/src/ui/CallGraphView.tsx @@ -0,0 +1,145 @@ +import { Box, Text } from "ink"; +import type { AgentEvent, AgentName } from "../schema.js"; +import { + aggregateSubtree, + buildCallGraph, + flatten, + type CallGraphNode, +} from "../util/call-graph.js"; +import { formatUSD } from "../util/cost.js"; + +interface Props { + events: AgentEvent[]; + rootSessionId: string; + selectedIdx: number; + viewportRows: number; +} + +export function CallGraphView({ + events, + rootSessionId, + selectedIdx, + viewportRows, +}: Props) { + const tree = buildCallGraph(events, rootSessionId); + if (!tree) { + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan">Call graph</Text> + <Text dimColor>(no events found in this session yet)</Text> + </Box> + ); + } + const agg = aggregateSubtree(tree); + const rows = flatten(tree); + const height = Math.max(3, viewportRows - 5); + const first = Math.max(0, Math.min(rows.length - height, selectedIdx - 2)); + const visible = rows.slice(first, first + height); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan">Call graph</Text> + <Text dimColor> + session {rootSessionId.slice(0, 12)} ·{" "} + {agg.agents.size} agent{agg.agents.size === 1 ? "" : "s"} ·{" "} + {agg.totalEvents} event{agg.totalEvents === 1 ? "" : "s"} · total{" "} + {formatUSD(agg.totalCost)} · {(agg.totalInput + agg.totalOutput).toLocaleString()} tokens + </Text> + <Text dimColor>[↑↓] navigate [enter] open node session [g/esc] back</Text> + <Box flexDirection="column" marginTop={1}> + {visible.map((row, i) => ( + <CallRow + key={row.node.eventId} + row={row} + selected={first + i === selectedIdx} + depth={row.depth} + isLast={row.isLast} + /> + ))} + </Box> + </Box> + ); +} + +function CallRow({ + row, + selected, + depth, + isLast, +}: { + row: { depth: number; node: CallGraphNode; isLast: boolean }; + selected: boolean; + depth: number; + isLast: boolean; +}) { + const node = row.node; + // Build the indent prefix using box-drawing chars. + // Top-level (depth 0) has no prefix; subsequent levels get + // "│ " for ancestors that have more siblings, " " otherwise, + // capped by "├── " or "└── " for the current level. + let prefix = ""; + for (let d = 0; d < depth - 1; d++) prefix += "│ "; + if (depth > 0) prefix += isLast ? "└── " : "├── "; + + const labelColor = node.kind === "session" ? agentColor(node.agent) : "yellow"; + const label = + node.kind === "session" + ? `[${node.agent}] ${(node.sessionId ?? "").slice(0, 10)}` + : `→ ${node.callee}: ${truncate(node.prompt ?? "(no prompt)", 50)}`; + + const metrics: string[] = []; + if (node.cost > 0) metrics.push(formatUSD(node.cost)); + const tokens = node.inputTokens + node.outputTokens; + if (tokens > 0) metrics.push(`${tokens.toLocaleString()}t`); + if (node.kind === "session" && node.events > 0) { + metrics.push(`${node.events}ev`); + } + + return ( + <Box> + <Text wrap="truncate" inverse={selected}> + <Text dimColor>{prefix}</Text> + <Text color={labelColor}>{label}</Text> + {metrics.length > 0 && <Text dimColor> · {metrics.join(" · ")}</Text>} + </Text> + </Box> + ); +} + +function agentColor(agent: AgentName | undefined): string { + switch (agent) { + case "claude-code": return "cyan"; + case "codex": return "green"; + case "gemini": return "blue"; + case "cursor": return "magenta"; + case "openclaw": return "yellow"; + default: return "white"; + } +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} + +/** Total node count so the App reducer can clamp the selection index. */ +export function callGraphRowCount( + events: AgentEvent[], + rootSessionId: string, +): number { + const tree = buildCallGraph(events, rootSessionId); + if (!tree) return 0; + return flatten(tree).length; +} + +/** The session id at the given selected index, for Enter to drill in. */ +export function callGraphSelectedSession( + events: AgentEvent[], + rootSessionId: string, + selectedIdx: number, +): string | null { + const tree = buildCallGraph(events, rootSessionId); + if (!tree) return null; + const flat = flatten(tree); + const node = flat[selectedIdx]?.node; + return node?.sessionId ?? null; +} diff --git a/src/ui/CompactionView.tsx b/src/ui/CompactionView.tsx new file mode 100644 index 0000000..4c17d76 --- /dev/null +++ b/src/ui/CompactionView.tsx @@ -0,0 +1,124 @@ +import { Box, Text } from "ink"; +import { + buildCompactionSeries, + renderCompactionBar, + type CompactionSeries, +} from "../util/compaction.js"; +import type { AgentEvent } from "../schema.js"; + +interface Props { + events: AgentEvent[]; + sessionId: string; + /** Index of the selected point within series.points; used to drill + * into a specific compaction marker or turn. */ + selectedIdx: number; + viewportCols: number; +} + +export function CompactionView({ + events, + sessionId, + selectedIdx, + viewportCols, +}: Props) { + const series = buildCompactionSeries(events, sessionId); + const barWidth = Math.min(series.points.length, Math.max(10, viewportCols - 10)); + const bar = renderCompactionBar(series, barWidth); + const tail = series.points.slice(-barWidth); + const selectedAbs = Math.max( + 0, + Math.min(series.points.length - 1, selectedIdx), + ); + const selectedInTail = tail.indexOf(series.points[selectedAbs]!); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text> + <Text bold color="cyan">Context compaction — </Text> + <Text>{sessionId.slice(0, 12)}</Text> + <Text dimColor>{" "}window {series.contextWindow.toLocaleString()}</Text> + <Text dimColor>{" "}turns {series.points.filter((p) => p.kind === "turn").length}</Text> + <Text dimColor>{" "}compactions {series.compactionCount}</Text> + <Text dimColor>{" "}max fill {(series.maxFill * 100).toFixed(0)}%</Text> + </Text> + <Text dimColor>[←→] select point [esc] back [C] close</Text> + <Box marginTop={1}> + <Text> + <Text dimColor>0% </Text> + {bar.split("").map((ch, i) => ( + <Text + key={i} + color={ch === "⋈" ? "red" : fillColor(tail[i]?.fillBefore ?? 0)} + inverse={i === selectedInTail} + > + {ch} + </Text> + ))} + <Text dimColor> 100%</Text> + </Text> + </Box> + <Box marginTop={1}> + <SelectedPointDetail series={series} idx={selectedAbs} /> + </Box> + </Box> + ); +} + +function SelectedPointDetail({ + series, + idx, +}: { + series: CompactionSeries; + idx: number; +}) { + const point = series.points[idx]; + if (!point) { + return <Text dimColor>(no points)</Text>; + } + if (point.kind === "compaction") { + const before = point.tokensBefore ?? 0; + const after = point.tokensAfter ?? 0; + const dropped = before - after; + return ( + <Box flexDirection="column"> + <Text> + <Text color="red" bold>⋈ compaction at {point.ts.slice(11, 19)}</Text> + </Text> + <Text dimColor> + before: {before.toLocaleString()} tokens ({(point.fillBefore * 100).toFixed(0)}% full) + </Text> + <Text dimColor> + after: {after.toLocaleString()} tokens + </Text> + <Text> + <Text color="green">dropped {dropped.toLocaleString()} tokens</Text> + </Text> + </Box> + ); + } + return ( + <Box flexDirection="column"> + <Text> + <Text bold>turn {point.label}</Text> + <Text dimColor> {point.ts.slice(11, 19)}</Text> + </Text> + <Text dimColor> + {point.tokensBefore?.toLocaleString() ?? "?"} tokens ({(point.fillBefore * 100).toFixed(0)}% full) + </Text> + </Box> + ); +} + +function fillColor(f: number): string { + if (f >= 0.9) return "red"; + if (f >= 0.75) return "yellow"; + if (f >= 0.4) return "green"; + return "cyan"; +} + +export function compactionPointCount( + events: AgentEvent[], + sessionId: string, +): number { + return buildCompactionSeries(events, sessionId).points.length; +} diff --git a/src/ui/EventDetail.tsx b/src/ui/EventDetail.tsx new file mode 100644 index 0000000..f0fa163 --- /dev/null +++ b/src/ui/EventDetail.tsx @@ -0,0 +1,170 @@ +import { Box, Text } from "ink"; +import type { AgentEvent } from "../schema.js"; +import { formatUSD } from "../util/cost.js"; +import { highlight, inferLang } from "../util/highlight.js"; + +interface Props { + event: AgentEvent; + width: number; + height: number; + scrollOffset: number; +} + +export function EventDetail({ event, width, height, scrollOffset }: Props) { + const rows = buildRows(event, width); + const visible = rows.slice(scrollOffset, scrollOffset + height - 4); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color={colorFor(event)}> + {event.ts.slice(11, 19)} — {event.agent} — {event.type} + {event.tool ? ` (${event.tool})` : ""} + </Text> + {event.path && ( + <Text dimColor>path: {event.path}</Text> + )} + {event.cmd && ( + <Text dimColor>cmd: {truncateLine(event.cmd, width - 6)}</Text> + )} + <Box marginTop={1} flexDirection="column"> + {visible.length === 0 ? ( + <Text dimColor>(no additional content captured for this event)</Text> + ) : ( + visible.map((r, i) => <Row key={i} row={r} />) + )} + </Box> + <Box marginTop={1}> + <Text dimColor> + {rows.length > height - 4 + ? `${scrollOffset + 1}–${Math.min(scrollOffset + height - 4, rows.length)} of ${rows.length} ↑↓ scroll ` + : ""} + [esc] close + </Text> + </Box> + </Box> + ); +} + +type Row = + | { kind: "heading"; text: string } + | { kind: "text"; text: string; dim?: boolean }; + +function buildRows(event: AgentEvent, width: number): Row[] { + const d = event.details; + const rows: Row[] = []; + const max = Math.max(40, width - 4); + + if (d?.usage || d?.cost != null || d?.durationMs != null) { + rows.push({ kind: "heading", text: "tokens / cost / duration" }); + const u = d?.usage; + if (u) { + rows.push({ + kind: "text", + text: `in=${u.input} cache_create=${u.cacheCreate} cache_read=${u.cacheRead} out=${u.output}`, + dim: true, + }); + } + if (d?.cost != null) { + rows.push({ + kind: "text", + text: `cost: ${formatUSD(d.cost)}${d.model ? ` (${d.model})` : ""}`, + dim: true, + }); + } + if (d?.durationMs != null) { + rows.push({ + kind: "text", + text: `duration: ${formatDuration(d.durationMs)}${d.toolError ? " — ERROR" : ""}`, + dim: true, + }); + } + } + + if (d?.toolResult) { + rows.push({ + kind: "heading", + text: d.toolError ? "tool result (error)" : "tool result", + }); + const lang = inferLang(event, d.toolResult); + const rendered = lang ? highlight(d.toolResult, lang) : d.toolResult; + for (const l of rendered.split("\n")) rows.push({ kind: "text", text: l }); + } + + if (d?.fullText) { + rows.push({ kind: "heading", text: "text" }); + for (const l of wrap(d.fullText, max)) rows.push({ kind: "text", text: l }); + } + + if (d?.thinking) { + rows.push({ kind: "heading", text: "extended thinking" }); + for (const l of wrap(d.thinking, max)) + rows.push({ kind: "text", text: l, dim: true }); + } + + if (d?.toolInput) { + rows.push({ kind: "heading", text: "tool input" }); + const pretty = JSON.stringify(d.toolInput, null, 2); + const rendered = highlight(pretty, "json"); + for (const l of rendered.split("\n")) rows.push({ kind: "text", text: l }); + } + + if (d?.toolUseId) { + rows.push({ kind: "text", text: "", dim: true }); + rows.push({ kind: "text", text: `tool_use_id: ${d.toolUseId}`, dim: true }); + } + + return rows; +} + +function Row({ row }: { row: Row }) { + if (row.kind === "heading") { + return ( + <Box marginTop={1}> + <Text bold color="cyan">— {row.text} —</Text> + </Box> + ); + } + return <Text dimColor={row.dim}>{row.text || " "}</Text>; +} + +function wrap(text: string, width: number): string[] { + const out: string[] = []; + for (const line of text.split("\n")) { + if (line.length <= width) { + out.push(line); + continue; + } + let rest = line; + while (rest.length > width) { + out.push(rest.slice(0, width)); + rest = rest.slice(width); + } + if (rest) out.push(rest); + } + return out; +} + +function truncateLine(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`; +} + +function colorFor(e: AgentEvent): string { + switch (e.agent) { + case "claude-code": return "cyan"; + case "openclaw": return "yellow"; + case "cursor": return "magenta"; + case "codex": return "green"; + case "gemini": return "blue"; + default: return "white"; + } +} + +export function totalDetailRows(event: AgentEvent, width: number): number { + return buildRows(event, width).length; +} diff --git a/src/ui/Header.tsx b/src/ui/Header.tsx index 21c9979..8c9cba1 100644 --- a/src/ui/Header.tsx +++ b/src/ui/Header.tsx @@ -1,38 +1,131 @@ import { Box, Text } from "ink"; import type { AgentName } from "../schema.js"; +import type { BudgetStatus } from "../util/budgets.js"; +import type { AnomalyFlag, SessionAnomalySummary } from "../util/anomaly.js"; +import { formatUSD } from "../util/cost.js"; +import { VERSION } from "../util/version.js"; interface Props { workspace: string; eventCount: number; filter: AgentName | null; paused: boolean; + budget?: BudgetStatus; + anomalies?: Map<string, AnomalyFlag[]>; + sessionAnomalies?: SessionAnomalySummary[]; + webUrl?: string; } -export function Header({ workspace, eventCount, filter, paused }: Props) { +export type { Props as HeaderProps }; + +export function Header({ + workspace, + eventCount, + filter, + paused, + budget, + anomalies, + sessionAnomalies, + webUrl, +}: Props) { + const breached = budget?.breachedSession || budget?.dayBreach; + const anomalyMessages = summarizeAnomalies(anomalies); + const sessionRows = (sessionAnomalies ?? []).slice(0, 2); return ( - <Box flexDirection="row" justifyContent="space-between" marginBottom={1}> - <Text> - <Text bold color="cyan">agentwatch </Text> - <Text dimColor>v0.0.1</Text> - </Text> - <Text> - <Text dimColor>workspace: </Text> - <Text>{workspace}</Text> - <Text dimColor> events: </Text> - <Text>{eventCount}</Text> - {filter && ( - <> - <Text dimColor> filter: </Text> - <Text color="yellow">{filter}</Text> - </> - )} - {paused && ( - <> - <Text dimColor> </Text> - <Text color="red">[PAUSED]</Text> - </> - )} - </Text> + <Box flexDirection="column" marginBottom={1}> + <Box flexDirection="row" justifyContent="space-between"> + <Text> + <Text bold color="cyan">agentwatch </Text> + <Text dimColor>v{VERSION}</Text> + </Text> + <Text> + <Text dimColor>workspace: </Text> + <Text>{workspace}</Text> + <Text dimColor> events: </Text> + <Text>{eventCount}</Text> + {filter && ( + <> + <Text dimColor> filter: </Text> + <Text color="yellow">{filter}</Text> + </> + )} + {paused && ( + <> + <Text dimColor> </Text> + <Text color="red">[PAUSED]</Text> + </> + )} + {webUrl && ( + <> + <Text dimColor> web: </Text> + <Text color="cyan">{webUrl}</Text> + <Text dimColor> [w]</Text> + </> + )} + </Text> + </Box> + {breached && budget && budget.sessionCost > 0 && ( + <Box> + <Text color="red" bold> + ⚠ BUDGET BREACH + </Text> + {budget.breachedSession && budget.perSessionUsd != null && ( + <Text color="red"> + {" session "} + {budget.breachedSession.slice(0, 8)} + {" "} + {formatUSD(budget.sessionCost)} + {" > cap "} + {formatUSD(budget.perSessionUsd)} + </Text> + )} + {budget.dayBreach && budget.perDayUsd != null && ( + <Text color="red"> + {" today "} + {formatUSD(budget.dayCost)} + {" > cap "} + {formatUSD(budget.perDayUsd)} + </Text> + )} + </Box> + )} + {sessionRows.map((s) => { + const total = + s.counts.cost + s.counts.duration + s.counts.tokens + s.counts["stuck-loop"]; + return ( + <Text key={s.sessionId} color="red"> + ⚠ session {s.sessionId.slice(0, 8)} · {total} flag + {total === 1 ? "" : "s"} · {s.headline} + </Text> + ); + })} + {sessionRows.length === 0 && + anomalyMessages.map((msg) => ( + <Text key={msg} color="red"> + ⚠ anomaly: <Text bold>{msg}</Text> + </Text> + ))} + {(sessionRows.length > 0 || anomalyMessages.length > 0) && ( + <Text dimColor>[D] dismiss banner until the next anomaly</Text> + )} </Box> ); } + +function summarizeAnomalies( + map?: Map<string, AnomalyFlag[]>, +): string[] { + if (!map || map.size === 0) return []; + const seen = new Set<string>(); + const msgs: string[] = []; + for (const flags of map.values()) { + for (const f of flags) { + const key = `${f.kind}:${f.message}`; + if (seen.has(key)) continue; + seen.add(key); + msgs.push(f.message); + if (msgs.length >= 3) return msgs; + } + } + return msgs; +} diff --git a/src/ui/HelpView.tsx b/src/ui/HelpView.tsx new file mode 100644 index 0000000..4b9f778 --- /dev/null +++ b/src/ui/HelpView.tsx @@ -0,0 +1,110 @@ +import { Box, Text } from "ink"; + +interface Group { + title: string; + rows: Array<[string, string]>; +} + +const GROUPS: Group[] = [ + { + title: "Navigate", + rows: [ + ["↑ ↓ / j k", "move selection in timeline"], + ["enter", "open event detail pane"], + ["esc", "close current view / clear selection"], + ["0", "home — reset every view, filter, scope to defaults"], + ["P", "projects grid — every workspace on this machine"], + ["enter on project", "sessions list for that project (by date)"], + ["enter on session", "scoped timeline for that session"], + ["q / ctrl-c", "quit agentwatch"], + ], + }, + { + title: "Search (unified, opens with /)", + rows: [ + ["/", "open the unified search overlay"], + ["tab / 1 2 3", "switch mode — live · cross-session · semantic"], + ["enter (typing)", "run search in the active mode"], + ["↑↓ then enter", "open the selected hit"], + ], + }, + { + title: "Filter & scope", + rows: [ + ["f", "cycle agent filter (claude / codex / gemini / cursor / openclaw)"], + ["a", "toggle agent side panel"], + ["x", "drill into selected Agent event's subagent run"], + ["X", "unscope subagent"], + ["A", "clear project filter"], + ["Z", "clear every active filter / scope at once"], + ], + }, + { + title: "Actions", + rows: [ + ["y", "yank selected event content to clipboard"], + ["e", "export current session to ./agentwatch-export/*.{md,json}"], + ["space", "pause / resume live event stream"], + ["c", "clear event buffer"], + ["D", "dismiss the active anomaly banner"], + ], + }, + { + title: "Info views", + rows: [ + ["p", "permissions (Claude + Codex + Gemini + Cursor + OpenClaw)"], + ["t", "token attribution (only inside a scoped session)"], + ["C", "context compaction visualizer (only inside a scoped session)"], + ["g", "agent call graph (only inside a scoped session)"], + ["S", "scheduled tasks — cron + heartbeat (anywhere)"], + ["↑↓ / j k inside any view", "scroll"], + ], + }, + { + title: "Detail pane (open with enter)", + rows: [ + ["↑ ↓ / j k", "scroll detail content"], + ["esc", "close detail"], + ], + }, + { + title: "Help", + rows: [ + ["?", "toggle this help"], + ["esc", "close this help"], + ], + }, +]; + +export function HelpView() { + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan"> + agentwatch — keybindings + </Text> + <Text dimColor>Press ? or esc to close.</Text> + {GROUPS.map((g) => ( + <Box key={g.title} flexDirection="column" marginTop={1}> + <Text bold color="yellow"> + {g.title} + </Text> + {g.rows.map(([k, d]) => ( + <Text key={k}> + <Text color="green">{pad(k, 34)}</Text> + <Text> {d}</Text> + </Text> + ))} + </Box> + ))} + <Box marginTop={1}> + <Text dimColor> + docs: github.com/mishanefedov/agentwatch · issues + feature requests welcome + </Text> + </Box> + </Box> + ); +} + +function pad(s: string, n: number): string { + return s.length >= n ? s : s + " ".repeat(n - s.length); +} diff --git a/src/ui/PermissionView.tsx b/src/ui/PermissionView.tsx index 2188c38..d1e8669 100644 --- a/src/ui/PermissionView.tsx +++ b/src/ui/PermissionView.tsx @@ -1,93 +1,367 @@ import { Box, Text } from "ink"; import type { ClaudePermissions } from "../util/claude-permissions.js"; +import type { CursorStatus } from "../adapters/cursor.js"; +import type { OpenClawConfig } from "../util/openclaw-config.js"; +import type { CodexPermissions } from "../util/codex-permissions.js"; +import type { GeminiPermissions } from "../util/gemini-permissions.js"; interface Props { - permissions: ClaudePermissions[]; + claude: ClaudePermissions[]; + cursor?: CursorStatus; + openclaw: OpenClawConfig | null; + codex?: CodexPermissions; + gemini?: GeminiPermissions; + /** How many rows (beyond header + footer) can fit in the visible box. */ + viewportRows: number; + /** Scroll offset in rows, 0 = top. */ + scrollOffset: number; } -export function PermissionView({ permissions }: Props) { - if (permissions.length === 0) { - return ( - <Box flexDirection="column" borderStyle="double" paddingX={1}> - <Text bold color="cyan">Permissions — Claude Code</Text> - <Text dimColor>No settings.json found at ~/.claude/ or project .claude/</Text> - <Text dimColor>Press p to close.</Text> - </Box> - ); - } +type Row = + | { kind: "h1"; text: string; color?: string } + | { kind: "h2"; text: string; color?: string } + | { kind: "kv"; label: string; value: string; valueColor?: string } + | { kind: "item"; mark: string; markColor?: string; text: string } + | { kind: "text"; text: string; dim?: boolean; color?: string } + | { kind: "blank" }; + +export function PermissionView({ + claude, + cursor, + openclaw, + codex, + gemini, + viewportRows, + scrollOffset, +}: Props) { + const rows = buildRows(claude, cursor, openclaw, codex, gemini); + const height = Math.max(3, viewportRows); + const maxScroll = Math.max(0, rows.length - height); + const offset = Math.min(scrollOffset, maxScroll); + const visible = rows.slice(offset, offset + height); return ( <Box flexDirection="column" borderStyle="double" paddingX={1}> - <Text bold color="cyan">Permissions — Claude Code</Text> - {permissions.map((p) => ( - <Block key={p.source} perms={p} /> - ))} + <Text bold color="cyan">Permissions / Configuration across installed agents</Text> + <Box flexDirection="column" marginTop={1}> + {visible.map((row, i) => ( + <RowView key={i} row={row} /> + ))} + </Box> <Box marginTop={1}> - <Text dimColor>Press p to close. These are the permissions Claude Code uses on your machine.</Text> + <Text dimColor> + {rows.length > height + ? `${offset + 1}–${offset + visible.length} of ${rows.length} [↑↓] scroll ` + : ""} + [p] close [q] quit + </Text> </Box> </Box> ); } -function Block({ perms }: { perms: ClaudePermissions }) { - return ( - <Box flexDirection="column" marginTop={1}> - <Text> - <Text dimColor>source: </Text> - <Text>{perms.source}</Text> - </Text> - <Text> - <Text dimColor>defaultMode: </Text> - <Text color={modeColor(perms.defaultMode)}>{perms.defaultMode}</Text> - </Text> +function RowView({ row }: { row: Row }) { + switch (row.kind) { + case "h1": + return ( + <Text bold color={row.color ?? "cyan"}> + ━ {row.text} ━ + </Text> + ); + case "h2": + return ( + <Text bold color={row.color ?? "white"}> + {row.text} + </Text> + ); + case "kv": + return ( + <Text> + <Text dimColor>{row.label}: </Text> + <Text color={row.valueColor}>{row.value}</Text> + </Text> + ); + case "item": + return ( + <Text> + {" "} + <Text color={row.markColor}>{row.mark}</Text> + <Text> {row.text}</Text> + </Text> + ); + case "text": + return ( + <Text color={row.color} dimColor={row.dim}> + {row.text || " "} + </Text> + ); + case "blank": + return <Text> </Text>; + } +} - <Box marginTop={1} flexDirection="column"> - <Text bold color="green">CAN ({perms.allow.length})</Text> - {perms.allow.length === 0 ? ( - <Text dimColor> (none — defaultMode applies)</Text> - ) : ( - perms.allow.map((a, i) => ( - <Text key={i}> <Text color="green">✓</Text> {a}</Text> - )) - )} - </Box> +function buildRows( + claude: ClaudePermissions[], + cursor: CursorStatus | undefined, + openclaw: OpenClawConfig | null, + codex?: CodexPermissions, + gemini?: GeminiPermissions, +): Row[] { + const rows: Row[] = []; - <Box marginTop={1} flexDirection="column"> - <Text bold color="red">CANNOT ({perms.deny.length})</Text> - {perms.deny.length === 0 ? ( - <Text dimColor> (none — no explicit denies)</Text> - ) : ( - perms.deny.map((d, i) => ( - <Text key={i}> <Text color="red">✗</Text> {d}</Text> - )) - )} - </Box> + // ─── Claude ─────────────────────────────────────────────────────────── + rows.push({ kind: "h1", text: "Claude Code", color: "cyan" }); + if (claude.length === 0) { + rows.push({ kind: "text", text: " No settings.json found.", dim: true }); + } else { + for (const perms of claude) { + rows.push({ kind: "blank" }); + rows.push({ kind: "kv", label: "source", value: perms.source }); + rows.push({ + kind: "kv", + label: "defaultMode", + value: perms.defaultMode, + valueColor: modeColor(perms.defaultMode), + }); + rows.push({ kind: "blank" }); + rows.push({ + kind: "h2", + text: `CAN (${perms.allow.length})`, + color: "green", + }); + if (perms.allow.length === 0) { + rows.push({ + kind: "text", + text: " (none — defaultMode applies)", + dim: true, + }); + } else { + for (const a of perms.allow) + rows.push({ kind: "item", mark: "✓", markColor: "green", text: a }); + } + rows.push({ kind: "blank" }); + rows.push({ + kind: "h2", + text: `CANNOT (${perms.deny.length})`, + color: "red", + }); + if (perms.deny.length === 0) { + rows.push({ + kind: "text", + text: " (none — no explicit denies)", + dim: true, + }); + } else { + for (const d of perms.deny) + rows.push({ kind: "item", mark: "✗", markColor: "red", text: d }); + } + if (perms.flags.length > 0) { + rows.push({ kind: "blank" }); + rows.push({ kind: "h2", text: "⚠ Flags", color: "yellow" }); + for (const f of perms.flags) { + rows.push({ + kind: "item", + mark: f.level === "risk" ? "✗" : "!", + markColor: f.level === "risk" ? "red" : "yellow", + text: f.message, + }); + } + } + } + } + + // ─── Cursor ─────────────────────────────────────────────────────────── + rows.push({ kind: "blank" }); + rows.push({ kind: "h1", text: "Cursor", color: "magenta" }); + if (!cursor?.installed) { + rows.push({ kind: "text", text: " not detected", dim: true }); + } else { + rows.push({ kind: "blank" }); + if (cursor.permissions) { + rows.push({ + kind: "kv", + label: "approvalMode", + value: cursor.permissions.approvalMode, + valueColor: modeColor(cursor.permissions.approvalMode), + }); + rows.push({ + kind: "kv", + label: "sandbox", + value: cursor.permissions.sandboxMode, + valueColor: + cursor.permissions.sandboxMode === "disabled" ? "red" : "green", + }); + rows.push({ + kind: "text", + text: ` allow: ${cursor.permissions.allowCount} deny: ${cursor.permissions.denyCount}`, + }); + } + rows.push({ + kind: "kv", + label: "MCP servers", + value: + cursor.mcpServers.length === 0 + ? "none" + : `${cursor.mcpServers.length} (${cursor.mcpServers.join(", ")})`, + }); + rows.push({ + kind: "kv", + label: ".cursorrules discovered", + value: String(cursor.cursorRulesFiles.length), + }); + for (const f of cursor.cursorRulesFiles.slice(0, 10)) + rows.push({ kind: "text", text: ` • ${f}`, dim: true }); + } - {perms.additionalDirectories.length > 0 && ( - <Box marginTop={1} flexDirection="column"> - <Text bold>Additional writable directories</Text> - {perms.additionalDirectories.map((d, i) => ( - <Text key={i}> • {d}</Text> - ))} - </Box> - )} + // ─── OpenClaw ───────────────────────────────────────────────────────── + rows.push({ kind: "blank" }); + rows.push({ kind: "h1", text: "OpenClaw", color: "yellow" }); + if (!openclaw) { + rows.push({ kind: "text", text: " not detected", dim: true }); + } else { + rows.push({ kind: "blank" }); + rows.push({ kind: "kv", label: "source", value: openclaw.source }); + if (openclaw.defaultWorkspace) { + rows.push({ + kind: "kv", + label: "default workspace", + value: openclaw.defaultWorkspace, + }); + } + rows.push({ + kind: "text", + text: " OpenClaw runs with broad shell + file access per agent. No allow/deny list — scope is the workspace path.", + dim: true, + }); + rows.push({ kind: "blank" }); + rows.push({ + kind: "h2", + text: `Sub-agents (${openclaw.agents.length})`, + }); + if (openclaw.agents.length === 0) { + rows.push({ kind: "text", text: " (none configured)", dim: true }); + } else { + for (const a of openclaw.agents) { + rows.push({ kind: "blank" }); + rows.push({ + kind: "text", + text: `${a.emoji ?? "•"} ${a.name ?? a.id} (id: ${a.id}${a.default ? ", default" : ""})`, + }); + if (a.model) rows.push({ kind: "text", text: ` model: ${a.model}`, dim: true }); + if (a.workspace) + rows.push({ kind: "text", text: ` workspace: ${a.workspace}`, dim: true }); + } + } + } - {perms.flags.length > 0 && ( - <Box marginTop={1} flexDirection="column"> - <Text bold color="yellow">⚠ Flags</Text> - {perms.flags.map((f, i) => ( - <Text key={i} color={f.level === "risk" ? "red" : "yellow"}> - {" "}{f.level === "risk" ? "✗" : "!"} {f.message} - </Text> - ))} - </Box> - )} - </Box> - ); + rows.push({ kind: "blank" }); + rows.push({ + kind: "text", + text: "Gemini CLI exposes no permission model beyond auth, so it is omitted.", + dim: true, + }); + + // ─── Codex ─────────────────────────────────────────────────────────── + if (codex) { + rows.push({ kind: "blank" }); + rows.push({ kind: "h1", text: "Codex", color: "green" }); + if (!codex.present) { + rows.push({ kind: "text", text: " No ~/.codex/config.toml found.", dim: true }); + } else { + rows.push({ kind: "kv", label: "config", value: codex.configPath }); + if (codex.model) rows.push({ kind: "kv", label: "model", value: codex.model }); + if (codex.approvalPolicy) { + rows.push({ + kind: "kv", + label: "approval_policy", + value: codex.approvalPolicy, + valueColor: codex.approvalPolicy === "never" ? "red" : "yellow", + }); + } + if (codex.sandboxPolicy) { + rows.push({ + kind: "kv", + label: "sandbox_policy", + value: codex.sandboxPolicy, + valueColor: + codex.sandboxPolicy === "danger-full-access" ? "red" : "green", + }); + } + if (codex.networkAccess !== undefined) { + rows.push({ + kind: "kv", + label: "network_access", + value: String(codex.networkAccess), + valueColor: codex.networkAccess ? "red" : "green", + }); + } + if (codex.writableRoots && codex.writableRoots.length > 0) { + rows.push({ kind: "h2", text: "Writable roots" }); + for (const r of codex.writableRoots.slice(0, 8)) { + rows.push({ kind: "item", mark: "●", markColor: "yellow", text: r }); + } + } + if (codex.projects.length > 0) { + rows.push({ kind: "h2", text: "Projects" }); + for (const p of codex.projects.slice(0, 10)) { + rows.push({ + kind: "item", + mark: "●", + markColor: p.trustLevel === "trusted" ? "green" : "yellow", + text: `${p.cwd} (${p.trustLevel})`, + }); + } + } + } + } + + // ─── Gemini CLI ────────────────────────────────────────────────────── + if (gemini) { + rows.push({ kind: "blank" }); + rows.push({ kind: "h1", text: "Gemini CLI", color: "blue" }); + if (!gemini.present) { + rows.push({ kind: "text", text: " No ~/.gemini/settings.json found.", dim: true }); + } else { + rows.push({ kind: "kv", label: "settings", value: gemini.settingsPath }); + if (gemini.authType) + rows.push({ kind: "kv", label: "auth", value: gemini.authType }); + if (gemini.selectedModel) + rows.push({ kind: "kv", label: "model", value: gemini.selectedModel }); + if (gemini.toolsAllow && gemini.toolsAllow.length > 0) { + rows.push({ kind: "h2", text: "Allowed tools" }); + for (const t of gemini.toolsAllow.slice(0, 10)) + rows.push({ kind: "item", mark: "✓", markColor: "green", text: t }); + } + if (gemini.toolsBlock && gemini.toolsBlock.length > 0) { + rows.push({ kind: "h2", text: "Blocked tools" }); + for (const t of gemini.toolsBlock.slice(0, 10)) + rows.push({ kind: "item", mark: "✗", markColor: "red", text: t }); + } + if (gemini.trustedFolders.length > 0) { + rows.push({ kind: "h2", text: "Trusted folders" }); + for (const f of gemini.trustedFolders.slice(0, 8)) { + rows.push({ kind: "item", mark: "●", markColor: "green", text: f }); + } + } + } + } + + return rows; } function modeColor(mode: string): string { if (mode === "auto" || mode === "bypassPermissions") return "red"; - if (mode === "ask") return "green"; + if (mode === "ask" || mode === "allowlist") return "green"; return "yellow"; } + +/** Row count so callers can compute scroll bounds. */ +export function permissionRowCount( + claude: ClaudePermissions[], + cursor: CursorStatus | undefined, + openclaw: OpenClawConfig | null, + codex?: CodexPermissions, + gemini?: GeminiPermissions, +): number { + return buildRows(claude, cursor, openclaw, codex, gemini).length; +} diff --git a/src/ui/ProjectsView.tsx b/src/ui/ProjectsView.tsx new file mode 100644 index 0000000..4cfaf63 --- /dev/null +++ b/src/ui/ProjectsView.tsx @@ -0,0 +1,93 @@ +import { Box, Text } from "ink"; +import type { ProjectRow } from "../util/project-index.js"; +import { agoFromNow, isStale } from "../util/project-index.js"; +import { formatUSD } from "../util/cost.js"; +import type { AgentName } from "../schema.js"; + +interface Props { + projects: ProjectRow[]; + selectedIdx: number; + searchQuery: string; +} + +export function ProjectsView({ projects, selectedIdx, searchQuery }: Props) { + const filtered = searchQuery + ? projects.filter((p) => + p.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : projects; + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan"> + Projects — {filtered.length} workspace{filtered.length === 1 ? "" : "s"} + </Text> + <Text dimColor> + sorted by last activity · enter to filter timeline · esc to close + </Text> + <Box marginTop={1} flexDirection="column"> + {filtered.length === 0 ? ( + <Text dimColor> + No projects yet. Use Claude Code / OpenClaw / Cursor and they'll + show up here as events stream in. + </Text> + ) : ( + filtered.map((p, i) => ( + <ProjectRowView + key={p.name} + row={p} + selected={i === selectedIdx} + /> + )) + )} + </Box> + </Box> + ); +} + +function ProjectRowView({ + row, + selected, +}: { + row: ProjectRow; + selected: boolean; +}) { + const agentCounts = Array.from(row.byAgent.entries()) + .sort(([, a], [, b]) => b - a) + .map(([name, n]) => `${shortName(name)}:${n}`) + .join(" "); + return ( + <Box flexDirection="column" marginTop={1}> + <Text inverse={selected} dimColor={isStale(row.lastTs)}> + <Text color="yellow" bold> + {selected ? "▶ " : " "} + </Text> + <Text bold>{row.name.padEnd(26)}</Text> + <Text dimColor> {agoFromNow(row.lastTs).padStart(10)}</Text> + {row.cost > 0 && ( + <Text dimColor> + {" "} + <Text color={isStale(row.lastTs) ? undefined : "yellow"}>{formatUSD(row.cost)}</Text> + </Text> + )} + {isStale(row.lastTs) && <Text dimColor> · ⊘ stale</Text>} + </Text> + <Text dimColor> + {" "} + {row.events} events · {row.sessions.size} session + {row.sessions.size === 1 ? "" : "s"} · {agentCounts} + </Text> + </Box> + ); +} + +function shortName(a: AgentName): string { + switch (a) { + case "claude-code": return "claude"; + case "openclaw": return "openclaw"; + case "cursor": return "cursor"; + case "codex": return "codex"; + case "gemini": return "gemini"; + default: return a; + } +} diff --git a/src/ui/ScheduledView.tsx b/src/ui/ScheduledView.tsx new file mode 100644 index 0000000..812838e --- /dev/null +++ b/src/ui/ScheduledView.tsx @@ -0,0 +1,300 @@ +import { Box, Text } from "ink"; +import type { AgentEvent } from "../schema.js"; +import { + humanizeMs, + readCronJobs, + type CronJob, +} from "../util/openclaw-cron.js"; +import { + readAllHeartbeats, + type HeartbeatTask, +} from "../util/openclaw-heartbeat.js"; +import { formatUSD } from "../util/cost.js"; + +/** + * One row per defined OpenClaw cron job + one row per HEARTBEAT.md + * task. Aggregates last-fired / runs-7d / cost-7d from any event in the + * buffer whose `details.scheduled` matches. + */ + +interface Props { + events: AgentEvent[]; + selectedIdx: number; + viewportRows: number; +} + +export interface ScheduledRow { + /** Stable id for navigation. For cron, the jobId; for heartbeat, + * workspace + task index. */ + id: string; + kind: "cron" | "heartbeat"; + label: string; + schedule: string; + /** Agent the job/heartbeat is tied to (`main`, `content`, …). */ + agentId?: string; + /** Most recent fire we've seen in the event buffer (ms). Undefined + * when no run has been observed yet. */ + lastFiredMs?: number; + /** Runs we've seen in the last 7 days. */ + runs7d: number; + /** Cumulative cost over those runs. */ + cost7d: number; + /** Computed status. */ + status: "ok" | "overdue" | "no-data" | "disabled"; + /** When overdue, how many ms past expected. */ + overdueByMs?: number; + /** Per-row navigation target — first sessionId we've seen for this + * scheduled task, so Enter can scope into the latest run. */ + latestSessionId?: string; +} + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60_000; +const OVERDUE_FACTOR = 1.5; + +export function ScheduledView({ events, selectedIdx, viewportRows }: Props) { + const rows = buildRows(events); + const height = Math.max(3, viewportRows - 4); + const first = Math.max(0, Math.min(rows.length - height, selectedIdx - 2)); + const visible = rows.slice(first, first + height); + + const totalCost = rows.reduce((s, r) => s + r.cost7d, 0); + const totalRuns = rows.reduce((s, r) => s + r.runs7d, 0); + const overdue = rows.filter((r) => r.status === "overdue").length; + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan">Scheduled tasks (cron + heartbeat)</Text> + <Text dimColor> + {rows.length} task{rows.length === 1 ? "" : "s"} · {totalRuns} runs / 7d · {formatUSD(totalCost)} / 7d + {overdue > 0 ? ` · ⚠ ${overdue} overdue` : ""} + </Text> + <Text dimColor>[↑↓] navigate [enter] open latest run [S/esc] close</Text> + <Box marginTop={1} flexDirection="column"> + {rows.length === 0 ? ( + <Text dimColor> + (no scheduled tasks defined — add one with `openclaw cron add` or + populate ~/.openclaw/workspace-*/HEARTBEAT.md) + </Text> + ) : ( + <> + <Header /> + {visible.map((r, i) => ( + <Row + key={r.id} + row={r} + selected={first + i === selectedIdx} + /> + ))} + </> + )} + </Box> + </Box> + ); +} + +function Header() { + return ( + <Text bold dimColor> + {pad("kind", 5)}{pad("name", 24)}{pad("schedule", 14)}{pad("agent", 10)}{pad("last", 12)}{pad("runs7d", 8)}{pad("cost7d", 10)}status + </Text> + ); +} + +function Row({ row, selected }: { row: ScheduledRow; selected: boolean }) { + const last = row.lastFiredMs ? agoFromNow(row.lastFiredMs) : "never"; + const status = renderStatus(row); + return ( + <Text wrap="truncate" inverse={selected}> + <Text color={row.kind === "cron" ? "magenta" : "yellow"}> + {pad(row.kind === "cron" ? "cron" : "♥hb", 5)} + </Text> + <Text bold>{pad(row.label, 24)}</Text> + <Text dimColor>{pad(row.schedule, 14)}</Text> + <Text dimColor>{pad(row.agentId ?? "?", 10)}</Text> + <Text dimColor>{pad(last, 12)}</Text> + <Text>{pad(String(row.runs7d), 8)}</Text> + <Text dimColor>{pad(row.cost7d > 0 ? formatUSD(row.cost7d) : "—", 10)}</Text> + {status} + </Text> + ); +} + +function renderStatus(row: ScheduledRow) { + if (row.status === "disabled") { + return <Text dimColor>disabled</Text>; + } + if (row.status === "overdue" && row.overdueByMs != null) { + return ( + <Text color="red">⚠ overdue {humanizeMs(row.overdueByMs)}</Text> + ); + } + if (row.status === "no-data") { + return <Text dimColor>(awaiting first run)</Text>; + } + return <Text color="green">✓ on schedule</Text>; +} + +function buildRows(events: AgentEvent[]): ScheduledRow[] { + const cronJobs = readCronJobs(); + const heartbeats = readAllHeartbeats(); + const aggregates = aggregateEvents(events); + const rows: ScheduledRow[] = []; + + for (const job of cronJobs) { + const agg = aggregates.cron.get(job.id); + rows.push(rowFromCron(job, agg)); + } + let hbIdx = 0; + for (const status of heartbeats) { + for (const task of status.tasks) { + const agg = aggregates.heartbeatByAgent.get(status.workspace); + rows.push(rowFromHeartbeat(task, agg, status.workspace, hbIdx++)); + } + } + // Sort overdue first, then by recency. + rows.sort((a, b) => { + if ((a.status === "overdue") !== (b.status === "overdue")) { + return a.status === "overdue" ? -1 : 1; + } + return (b.lastFiredMs ?? 0) - (a.lastFiredMs ?? 0); + }); + return rows; +} + +interface Aggregate { + lastFiredMs?: number; + runs7d: number; + cost7d: number; + latestSessionId?: string; +} + +function aggregateEvents(events: AgentEvent[]): { + cron: Map<string, Aggregate>; + heartbeatByAgent: Map<string, Aggregate>; +} { + const cron = new Map<string, Aggregate>(); + const heartbeatByAgent = new Map<string, Aggregate>(); + const now = Date.now(); + for (const e of events) { + const sched = e.details?.scheduled; + if (!sched) continue; + const ts = new Date(e.ts).getTime(); + const within7d = now - ts <= SEVEN_DAYS_MS; + const cost = e.details?.cost ?? 0; + if (sched.kind === "cron" && sched.jobId) { + const a = cron.get(sched.jobId) ?? bucket(); + bumpAggregate(a, ts, within7d, cost, e.sessionId); + cron.set(sched.jobId, a); + } else if (sched.kind === "heartbeat") { + const key = sched.agentId ?? "main"; + const a = heartbeatByAgent.get(key) ?? bucket(); + bumpAggregate(a, ts, within7d, cost, e.sessionId); + heartbeatByAgent.set(key, a); + } + } + return { cron, heartbeatByAgent }; +} + +function bucket(): Aggregate { + return { runs7d: 0, cost7d: 0 }; +} + +function bumpAggregate( + a: Aggregate, + ts: number, + within7d: boolean, + cost: number, + sessionId: string | undefined, +): void { + if (!a.lastFiredMs || ts > a.lastFiredMs) { + a.lastFiredMs = ts; + if (sessionId) a.latestSessionId = sessionId; + } + if (within7d) { + a.runs7d += 1; + a.cost7d += cost; + } +} + +function rowFromCron(job: CronJob, agg: Aggregate | undefined): ScheduledRow { + const status = computeCronStatus(job, agg); + return { + id: `cron:${job.id}`, + kind: "cron", + label: job.name, + schedule: job.schedule, + agentId: job.agentId, + lastFiredMs: agg?.lastFiredMs, + runs7d: agg?.runs7d ?? 0, + cost7d: agg?.cost7d ?? 0, + status: status.status, + overdueByMs: status.overdueByMs, + latestSessionId: agg?.latestSessionId, + }; +} + +function rowFromHeartbeat( + task: HeartbeatTask, + agg: Aggregate | undefined, + workspace: string, + idx: number, +): ScheduledRow { + return { + id: `hb:${workspace}:${idx}`, + kind: "heartbeat", + label: task.text, + // Heartbeat schedule is configured per-agent in gateway config — + // we surface "per agent" rather than guess the interval. + schedule: "per agent", + agentId: workspace.replace(/^workspace-/, ""), + lastFiredMs: agg?.lastFiredMs, + runs7d: agg?.runs7d ?? 0, + cost7d: agg?.cost7d ?? 0, + // Heartbeats don't have a defined cron expression we can compare + // against, so we don't compute overdue here. Skip-reason events + // (a follow-up) will surface that signal directly. + status: agg ? "ok" : "no-data", + latestSessionId: agg?.latestSessionId, + }; +} + +function computeCronStatus( + job: CronJob, + agg: Aggregate | undefined, +): { status: ScheduledRow["status"]; overdueByMs?: number } { + if (!job.enabled) return { status: "disabled" }; + if (!agg?.lastFiredMs) return { status: "no-data" }; + if (!job.intervalMs) return { status: "ok" }; + const elapsed = Date.now() - agg.lastFiredMs; + if (elapsed > job.intervalMs * OVERDUE_FACTOR) { + return { status: "overdue", overdueByMs: elapsed - job.intervalMs }; + } + return { status: "ok" }; +} + +function pad(s: string, n: number): string { + return s.length >= n ? s.slice(0, n - 1) + " " : s + " ".repeat(n - s.length); +} + +function agoFromNow(ms: number): string { + const diff = Date.now() - ms; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +/** Total row count so the App reducer can clamp navigation. */ +export function scheduledRowCount(events: AgentEvent[]): number { + return buildRows(events).length; +} + +/** Selected row's latest sessionId — for Enter to scope into the most + * recent run of that scheduled task. */ +export function scheduledSelectedSessionId( + events: AgentEvent[], + selectedIdx: number, +): string | null { + const rows = buildRows(events); + return rows[selectedIdx]?.latestSessionId ?? null; +} diff --git a/src/ui/SearchView.tsx b/src/ui/SearchView.tsx new file mode 100644 index 0000000..c89bb9b --- /dev/null +++ b/src/ui/SearchView.tsx @@ -0,0 +1,220 @@ +import { Box, Text } from "ink"; +import type { AgentEvent } from "../schema.js"; +import type { SearchHit as BmHit } from "../util/cross-search.js"; +import type { SearchHit as SemHit } from "../util/semantic-index.js"; + +export type SearchMode = "live" | "cross" | "semantic"; + +export type UnifiedHit = + | { kind: "live"; event: AgentEvent } + | { kind: "cross"; hit: BmHit } + | { kind: "semantic"; hit: SemHit }; + +interface Props { + mode: SearchMode; + query: string; + typing: boolean; + hits: UnifiedHit[]; + selectedIdx: number; + viewportRows: number; + /** Status text for long-running operations (e.g. semantic indexing). */ + statusText: string | null; + /** When set, the first-run consent modal is shown over the panel. */ + confirming: { query: string } | null; +} + +const MODE_LABELS: Record<SearchMode, string> = { + live: "Live timeline (substring, fast)", + cross: "Cross-session (every JSONL on disk)", + semantic: "Semantic (BM25 + local embeddings)", +}; + +const MODE_HINTS: Record<SearchMode, string> = { + live: + "Searches the 500-event ring buffer in memory. Substring match on summary, path, cmd, tool, full text.", + cross: + "Searches every Claude / Codex / Gemini / OpenClaw session file on disk. Substring match.", + semantic: + "Hybrid BM25 + sentence-embedding ranking over a local SQLite index. First run downloads ~80 MB.", +}; + +export function SearchView({ + mode, + query, + typing, + hits, + selectedIdx, + viewportRows, + statusText, + confirming, +}: Props) { + if (confirming) { + return ( + <Box flexDirection="column" borderStyle="double" borderColor="yellow" paddingX={1}> + <Text bold color="yellow">First-run setup — semantic search</Text> + <Text> </Text> + <Text> + Semantic search needs to download a sentence-embedding model + (<Text bold>bge-small-en-v1.5</Text>, ~80 MB) and build a local + index of every session file on disk. + </Text> + <Text> </Text> + <Text dimColor>Downloaded to: ~/.agentwatch/models/</Text> + <Text dimColor>Index at: ~/.agentwatch/index.sqlite</Text> + <Text dimColor>Estimated time: ~20s download + 1–3 min indexing</Text> + <Text dimColor>Disk use: ~100 MB (model) + ~50–150 MB (index)</Text> + <Text dimColor>Network: one-time HTTPS fetch from huggingface.co</Text> + <Text> </Text> + <Text>Query: <Text color="cyan">{confirming.query}</Text></Text> + <Text> </Text> + <Text bold> + Proceed? <Text color="green">[y]</Text> yes{" "} + <Text color="red">[n]</Text> no (esc cancels) + </Text> + </Box> + ); + } + + const height = Math.max(3, viewportRows - 4); + const first = Math.max(0, Math.min(hits.length - height, selectedIdx - 2)); + const visible = hits.slice(first, first + height); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text> + <Text bold color="cyan">Search </Text> + <Tab active={mode === "live"} label="1 live" /> + <Text> </Text> + <Tab active={mode === "cross"} label="2 cross-session" /> + <Text> </Text> + <Tab active={mode === "semantic"} label="3 semantic" /> + </Text> + <Text dimColor>{MODE_LABELS[mode]}</Text> + <Text dimColor>{MODE_HINTS[mode]}</Text> + <Box marginTop={1}> + <Text> + <Text color="yellow">/ </Text> + <Text>{query || ""}</Text> + {typing && <Text color="yellow">▌</Text>} + <Text dimColor> {hits.length} hit{hits.length === 1 ? "" : "s"}</Text> + </Text> + </Box> + <Text dimColor> + [tab / 1 2 3] mode [enter] run [↑↓] select [enter] open [esc] back + </Text> + {statusText && <Text color="yellow">{statusText}</Text>} + <Box flexDirection="column" marginTop={1}> + {hits.length === 0 ? ( + <EmptyHint mode={mode} query={query} typing={typing} statusText={statusText} /> + ) : ( + visible.map((h, i) => ( + <Row key={first + i} hit={h} selected={first + i === selectedIdx} /> + )) + )} + </Box> + </Box> + ); +} + +function EmptyHint({ + mode, + query, + typing, + statusText, +}: { + mode: SearchMode; + query: string; + typing: boolean; + statusText: string | null; +}) { + if (statusText) return <Text dimColor>{statusText}</Text>; + if (!query) return <Text dimColor>(type a query, then enter to run)</Text>; + if (typing) { + return ( + <Text dimColor> + (press enter to run {mode} search; tab to switch mode) + </Text> + ); + } + if (mode === "semantic") { + return ( + <Text dimColor> + (no semantic results — index may be empty. Press enter to rebuild.) + </Text> + ); + } + return <Text dimColor>(no matches)</Text>; +} + +function Tab({ active, label }: { active: boolean; label: string }) { + return ( + <Text + color={active ? "cyan" : undefined} + dimColor={!active} + bold={active} + > + {active ? `▶ ${label}` : ` ${label}`} + </Text> + ); +} + +function Row({ hit, selected }: { hit: UnifiedHit; selected: boolean }) { + if (hit.kind === "live") { + const e = hit.event; + const summary = e.summary ?? e.path ?? e.cmd ?? e.tool ?? e.type; + return ( + <Text wrap="truncate" inverse={selected}> + <Text color="yellow">{selected ? "▶ " : " "}</Text> + <Text dimColor>{e.ts.slice(11, 19)} </Text> + <Text color={agentColor(e.agent)}>{pad(e.agent, 12)}</Text> + <Text dimColor>{pad(e.type, 12)}</Text> + <Text>{summary}</Text> + </Text> + ); + } + if (hit.kind === "cross") { + const h = hit.hit; + return ( + <Text wrap="truncate" inverse={selected}> + <Text color="yellow">{selected ? "▶ " : " "}</Text> + <Text color={agentColor(h.agent)}>{pad(h.agent, 12)}</Text> + <Text dimColor>{pad(h.project || "(no project)", 14)}</Text> + <Text dimColor>{pad(h.sessionId.slice(0, 10), 11)}</Text> + <Text>{truncate(h.line.trim(), 60)}</Text> + </Text> + ); + } + const h = hit.hit; + const srcColor = + h.source === "H" ? "magenta" : h.source === "V" ? "blue" : "cyan"; + return ( + <Text wrap="truncate" inverse={selected}> + <Text color="yellow">{selected ? "▶ " : " "}</Text> + <Text color={srcColor}>{pad(h.source, 2)}</Text> + <Text color={agentColor(h.agent)}>{pad(h.agent, 12)}</Text> + <Text dimColor>{pad(h.project || "(no project)", 14)}</Text> + <Text dimColor>{pad("t" + h.turnIdx, 5)}</Text> + <Text>{truncate(h.label || h.sessionId.slice(0, 12), 60)}</Text> + <Text dimColor> {h.score.toFixed(3)}</Text> + </Text> + ); +} + +function agentColor(agent: string): string { + switch (agent) { + case "claude-code": return "cyan"; + case "codex": return "green"; + case "gemini": return "blue"; + case "cursor": return "magenta"; + case "openclaw": return "yellow"; + default: return "white"; + } +} + +function pad(s: string, n: number): string { + return s.length >= n ? s.slice(0, n) + " " : s + " ".repeat(n - s.length); +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} diff --git a/src/ui/SessionsView.tsx b/src/ui/SessionsView.tsx new file mode 100644 index 0000000..48ccf07 --- /dev/null +++ b/src/ui/SessionsView.tsx @@ -0,0 +1,148 @@ +import { Box, Text } from "ink"; +import type { SessionRow } from "../util/project-index.js"; +import { agoFromNow, dateBucket, isStale } from "../util/project-index.js"; +import { formatUSD } from "../util/cost.js"; +import type { AgentName } from "../schema.js"; + +interface Props { + project: string; + sessions: SessionRow[]; + selectedIdx: number; + viewportRows: number; + scrollOffset: number; +} + +type Line = + | { kind: "bucket"; label: string } + | { kind: "session"; row: SessionRow; absIdx: number }; + +export function SessionsView({ + project, + sessions, + selectedIdx, + viewportRows, + scrollOffset, +}: Props) { + const lines = buildLines(sessions); + const height = Math.max(3, viewportRows); + const maxScroll = Math.max(0, lines.length - height); + const offset = Math.min(scrollOffset, maxScroll); + const visible = lines.slice(offset, offset + height); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text> + <Text bold color="cyan"> + Sessions —{" "} + </Text> + <Text bold>{project}</Text> + <Text dimColor> + {" "} + {sessions.length} session{sessions.length === 1 ? "" : "s"} + </Text> + </Text> + <Text dimColor> + [↑↓] select [enter] open session [esc] back to projects [q] quit + </Text> + <Box flexDirection="column" marginTop={1}> + {visible.length === 0 ? ( + <Text dimColor>(no sessions found for this project)</Text> + ) : ( + visible.map((l, i) => <LineView key={i} line={l} selectedIdx={selectedIdx} />) + )} + </Box> + {lines.length > height && ( + <Box marginTop={1}> + <Text dimColor> + {offset + 1}–{offset + visible.length} of {lines.length} + </Text> + </Box> + )} + </Box> + ); +} + +function LineView({ line, selectedIdx }: { line: Line; selectedIdx: number }) { + if (line.kind === "bucket") { + return ( + <Box marginTop={1}> + <Text bold dimColor> + {bucketLabel(line.label)} + </Text> + </Box> + ); + } + const r = line.row; + const selected = line.absIdx === selectedIdx; + const agentTag = tagFor(r.agent, r.subAgent); + const stale = isStale(r.lastTs); + return ( + <Box> + <Text wrap="truncate" inverse={selected} dimColor={stale}> + <Text color="yellow">{selected ? "▶ " : " "}</Text> + <Text color={stale ? undefined : colorForAgent(r.agent)}>{pad(agentTag, 22)}</Text> + <Text> {truncate(r.firstPrompt || "(no user prompt yet)", 56)}</Text> + <Text dimColor> · {r.events}ev · {agoFromNow(r.lastTs)}</Text> + {r.cost > 0 && ( + <Text dimColor> + {" · "} + <Text color={stale ? undefined : "yellow"}>{formatUSD(r.cost)}</Text> + </Text> + )} + {r.hasError && <Text color="red"> · ERR</Text>} + {stale && <Text dimColor> · ⊘ stale</Text>} + </Text> + </Box> + ); +} + +function buildLines(sessions: SessionRow[]): Line[] { + const lines: Line[] = []; + let currentBucket = ""; + let idx = 0; + for (const row of sessions) { + const bucket = dateBucket(row.lastTs); + if (bucket !== currentBucket) { + currentBucket = bucket; + lines.push({ kind: "bucket", label: bucket }); + } + lines.push({ kind: "session", row, absIdx: idx++ }); + } + return lines; +} + +function bucketLabel(b: string): string { + if (b === "today") return "TODAY"; + if (b === "yesterday") return "YESTERDAY"; + if (b === "7d") return "LAST 7 DAYS"; + return "OLDER"; +} + +function tagFor(agent: AgentName, sub?: string): string { + if (sub) return `[${agent}:${sub}]`; + return `[${agent}]`; +} + +function colorForAgent(a: AgentName): string { + switch (a) { + case "claude-code": return "cyan"; + case "openclaw": return "yellow"; + case "cursor": return "magenta"; + case "codex": return "green"; + case "gemini": return "blue"; + default: return "white"; + } +} + +function pad(s: string, n: number): string { + return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length); +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} + +/** Total renderable lines so callers can compute scroll bounds. */ +export function sessionLineCount(sessions: SessionRow[]): number { + return buildLines(sessions).length; +} diff --git a/src/ui/Timeline.tsx b/src/ui/Timeline.tsx index 442fac5..d3eeb0d 100644 --- a/src/ui/Timeline.tsx +++ b/src/ui/Timeline.tsx @@ -1,45 +1,114 @@ import { Box, Text } from "ink"; import type { AgentEvent, AgentName } from "../schema.js"; +import type { AnomalyFlag } from "../util/anomaly.js"; interface Props { events: AgentEvent[]; + selectedIdx?: number | null; + childCountByAgentId?: Map<string, number>; + anomalies?: Map<string, AnomalyFlag[]>; } -export function Timeline({ events }: Props) { +export function Timeline({ + events, + selectedIdx, + childCountByAgentId, + anomalies, +}: Props) { + const header = ( + <Box> + <Text bold dimColor> + {"TIME "} + {pad("AGENT", 10)} + {" "} + {pad("TYPE", 13)} + {" "} + EVENT + </Text> + </Box> + ); + if (events.length === 0) { return ( - <Box> - <Text dimColor> - waiting for activity… use Claude Code or edit a file in your workspace - </Text> + <Box flexDirection="column"> + {header} + <Box marginTop={1}> + <Text dimColor> + waiting for activity… use Claude Code or edit a file in your workspace + </Text> + </Box> </Box> ); } - const visible = events.slice(0, 40); + // Keep the selected row in view if the user has navigated deep into history + const windowStart = + selectedIdx != null && selectedIdx > 30 + ? Math.max(0, selectedIdx - 15) + : 0; + const visible = events.slice(windowStart, windowStart + 40); return ( <Box flexDirection="column"> - {visible.map((e) => ( - <EventRow key={e.id} event={e} /> + {header} + {visible.map((e, i) => ( + <EventRow + key={e.id} + event={e} + selected={windowStart + i === selectedIdx} + childCount={ + e.details?.subAgentId + ? (childCountByAgentId?.get(e.details.subAgentId) ?? 0) + : 0 + } + flagged={anomalies?.has(e.id) ?? false} + /> ))} </Box> ); } -function EventRow({ event }: { event: AgentEvent }) { +function EventRow({ + event, + selected, + childCount, + flagged, +}: { + event: AgentEvent; + selected: boolean; + childCount: number; + flagged: boolean; +}) { const time = event.ts.slice(11, 19); - const line = event.summary ?? event.path ?? event.cmd ?? event.tool ?? event.type; + const baseLine = event.summary ?? event.path ?? event.cmd ?? event.tool ?? event.type; + const duration = event.details?.durationMs != null + ? ` · ${formatMs(event.details.durationMs)}` + : ""; + const err = event.details?.toolError ? " · ERR" : ""; + const marker = childCount > 0 ? ` ▸ ${childCount} child events` : ""; return ( <Box> - <Text dimColor>{time} </Text> - <Text color={agentColor(event.agent)}>{pad(event.agent, 12)} </Text> - <Text color={riskColor(event.riskScore)}>{pad(event.type, 12)} </Text> - <Text>{truncate(line, 100)}</Text> + <Text wrap="truncate" inverse={selected}> + <Text dimColor>{time} </Text> + <Text color={agentColor(event.agent)}>{pad(event.agent, 10)} </Text> + <Text color={flagged ? "red" : riskColor(event.riskScore)}> + {flagged ? "◎" : " "}{pad(event.type, 12)}{" "} + </Text> + <Text>{baseLine}</Text> + {duration && <Text dimColor>{duration}</Text>} + {err && <Text color="red">{err}</Text>} + {childCount > 0 && <Text color="yellow">{marker}</Text>} + </Text> </Box> ); } +function formatMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`; +} + function agentColor(a: AgentName): string { switch (a) { case "claude-code": return "cyan"; @@ -63,7 +132,3 @@ function pad(s: string, n: number): string { return s + " ".repeat(n - s.length); } -function truncate(s: string, n: number): string { - return s.length <= n ? s : s.slice(0, n - 1) + "…"; -} - diff --git a/src/ui/TokensView.tsx b/src/ui/TokensView.tsx new file mode 100644 index 0000000..245b94e --- /dev/null +++ b/src/ui/TokensView.tsx @@ -0,0 +1,198 @@ +import { Box, Text } from "ink"; +import type { + TokenBreakdown, + TurnBreakdown, +} from "../util/token-attribution.js"; +import { totalTokens } from "../util/token-attribution.js"; +import { formatUSD } from "../util/cost.js"; + +interface Props { + breakdown: TokenBreakdown; + turns: TurnBreakdown[]; + sessionId: string; + /** Index of the selected turn row; clamped to turns.length - 1. */ + selectedIdx: number; + viewportRows: number; +} + +const CATEGORIES: { + key: keyof TurnBreakdown; + label: string; + color: string; +}[] = [ + { key: "user", label: "user", color: "gray" }, + { key: "memoryFile", label: "memory file", color: "magenta" }, + { key: "toolIO", label: "tool I/O", color: "white" }, + { key: "thinking", label: "thinking", color: "blue" }, + { key: "input", label: "input (fresh)", color: "cyan" }, + { key: "cacheRead", label: "cache read", color: "green" }, + { key: "cacheCreate", label: "cache create", color: "yellow" }, + { key: "output", label: "output", color: "redBright" }, +]; + +export function TokensView({ + breakdown, + turns, + sessionId, + selectedIdx, + viewportRows, +}: Props) { + const total = + totalTokens(breakdown) + + breakdown.thinking + + breakdown.toolIO + + breakdown.user + + breakdown.memoryFile; + const aggregateRows = CATEGORIES.map((c) => { + const key = c.key as keyof TokenBreakdown; + const val = (breakdown[key] as number) ?? 0; + return { ...c, tokens: val }; + }); + + const selected = turns[selectedIdx] ?? turns[turns.length - 1]; + const maxTurnTotal = Math.max( + 1, + ...turns.map((t) => turnTotal(t)), + ); + const hasUsageData = turns.some( + (t) => t.input + t.cacheRead + t.cacheCreate + t.output > 0, + ); + + const visibleTurns = turns.slice( + Math.max(0, selectedIdx - 4), + Math.max(0, selectedIdx - 4) + Math.max(3, viewportRows - 12), + ); + + return ( + <Box flexDirection="column" borderStyle="double" paddingX={1}> + <Text bold color="cyan"> + Token attribution + </Text> + <Text dimColor> + session {sessionId.slice(0, 12)} · {breakdown.turns} assistant turn + {breakdown.turns === 1 ? "" : "s"} · total {total.toLocaleString()}{" "} + tokens · {formatUSD(breakdown.cost)} + </Text> + <Text dimColor>[↑↓] select turn [t] close [esc] back</Text> + <Box flexDirection="column" marginTop={1}> + <Text bold dimColor>Aggregate (session total)</Text> + {!hasUsageData && turns.length > 0 && ( + <Text color="yellow"> + ⚠ no token-usage data captured for this agent yet — budget, + cost, and usage columns will show 0. Only user / memory + file / thinking / tool I/O are tokenizer-measured from event + content. + </Text> + )} + {aggregateRows.map((r) => ( + <AggregateRow key={r.label} row={r} total={total} /> + ))} + </Box> + <Box flexDirection="column" marginTop={1}> + <Text bold dimColor> + Per turn (bar width ∝ total tokens that turn) + </Text> + {turns.length === 0 ? ( + <Text dimColor>(no assistant turns yet)</Text> + ) : ( + visibleTurns.map((t) => ( + <TurnRow + key={t.turnIdx} + turn={t} + selected={t.turnIdx === selected?.turnIdx} + maxTotal={maxTurnTotal} + /> + )) + )} + </Box> + <Box marginTop={1}> + <Text dimColor> + Tokens counted with a local cl100k_base tokenizer (gpt-tokenizer). + Claude's tokenizer is similar but not identical — expect ±5% vs + Anthropic's own counts. Usage numbers (input/cache/output) come + from the model's own usage record and are exact. + </Text> + </Box> + </Box> + ); +} + +function AggregateRow({ + row, + total, +}: { + row: { label: string; tokens: number; color: string }; + total: number; +}) { + const barWidth = 30; + const pct = total > 0 ? row.tokens / total : 0; + const filled = Math.round(pct * barWidth); + const bar = "█".repeat(filled) + "·".repeat(Math.max(0, barWidth - filled)); + return ( + <Text> + <Text color={row.color}>{pad(row.label, 16)}</Text> + <Text> {bar} </Text> + <Text>{row.tokens.toLocaleString().padStart(9)}</Text> + <Text dimColor> + {" "} + {`${(pct * 100).toFixed(1).padStart(5)}%`} + </Text> + </Text> + ); +} + +function TurnRow({ + turn, + selected, + maxTotal, +}: { + turn: TurnBreakdown; + selected: boolean; + maxTotal: number; +}) { + const barWidth = 40; + const total = turnTotal(turn); + const totalFilled = Math.round((total / maxTotal) * barWidth); + const bar = CATEGORIES.map((c) => { + const val = (turn[c.key as keyof TurnBreakdown] as number) ?? 0; + const width = total > 0 ? Math.round((val / total) * totalFilled) : 0; + return { color: c.color, width }; + }); + return ( + <Box> + <Text inverse={selected}> + <Text dimColor>{`t${turn.turnIdx}`.padStart(4)}</Text> + <Text> </Text> + {bar.map((b, i) => ( + <Text key={i} color={b.color}> + {"█".repeat(b.width)} + </Text> + ))} + <Text> + {"·".repeat(Math.max(0, barWidth - bar.reduce((a, b) => a + b.width, 0)))} + </Text> + <Text> {total.toLocaleString().padStart(7)}</Text> + {turn.cost > 0 && ( + <Text dimColor>{" "}{formatUSD(turn.cost)}</Text> + )} + </Text> + </Box> + ); +} + +function turnTotal(t: TurnBreakdown): number { + return ( + t.user + + t.memoryFile + + t.toolIO + + t.thinking + + t.input + + t.cacheRead + + t.cacheCreate + + t.output + ); +} + +function pad(s: string, n: number): string { + return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length); +} diff --git a/src/ui/state.test.ts b/src/ui/state.test.ts new file mode 100644 index 0000000..f194b9a --- /dev/null +++ b/src/ui/state.test.ts @@ -0,0 +1,566 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEvent } from "../schema.js"; +import { + MAX_EVENTS, + findInsertIdx, + initialState, + matchesQuery, + reducer, + type Action, + type State, +} from "./state.js"; + +/** + * Spec-driven reducer suite. Each block maps to a contract from + * docs/features/<feature>.md. When a feature's stated behavior changes, + * the corresponding test block MUST change with it — if you change the + * behavior and not the test, CI catches it; if you change the test and + * not the behavior, code review should catch it. + */ + +function makeEvent(partial: Partial<AgentEvent> & { ts: string }): AgentEvent { + return { + id: `e-${Math.random().toString(36).slice(2)}`, + agent: "claude-code", + type: "tool_call", + riskScore: 0, + summary: "", + ...partial, + }; +} + +function apply(state: State, ...actions: Action[]): State { + return actions.reduce(reducer, state); +} + +describe("findInsertIdx", () => { + it("returns 0 for the newest ts into an empty buffer", () => { + expect(findInsertIdx([], "2026-04-16T00:00:00Z")).toBe(0); + }); + + it("places a newer ts at the head (events are newest-first)", () => { + const events = [ + makeEvent({ ts: "2026-04-16T00:00:05Z" }), + makeEvent({ ts: "2026-04-16T00:00:00Z" }), + ]; + expect(findInsertIdx(events, "2026-04-16T00:00:10Z")).toBe(0); + }); + + it("places an older ts at the tail", () => { + const events = [ + makeEvent({ ts: "2026-04-16T00:00:05Z" }), + makeEvent({ ts: "2026-04-16T00:00:01Z" }), + ]; + expect(findInsertIdx(events, "2026-04-16T00:00:00Z")).toBe(2); + }); + + it("places a mid ts in between", () => { + const events = [ + makeEvent({ ts: "2026-04-16T00:00:10Z" }), + makeEvent({ ts: "2026-04-16T00:00:00Z" }), + ]; + expect(findInsertIdx(events, "2026-04-16T00:00:05Z")).toBe(1); + }); +}); + +describe("matchesQuery", () => { + const e = makeEvent({ + ts: "2026-04-16T00:00:00Z", + summary: "ran rm -rf /tmp", + path: "/etc/passwd", + cmd: "rm -rf", + tool: "Bash", + agent: "claude-code", + details: { fullText: "destructive command", thinking: "step one" }, + }); + + it.each([ + ["summary substring", "rm -rf"], + ["path substring", "passwd"], + ["cmd substring", "RM"], + ["tool", "bash"], + ["agent", "claude"], + ["details.fullText", "destructive"], + ["details.thinking", "step"], + ])("matches %s", (_label, q) => { + expect(matchesQuery(e, q)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(matchesQuery(e, "PASSWD")).toBe(true); + }); + + it("returns false on a miss", () => { + expect(matchesQuery(e, "completely unrelated")).toBe(false); + }); +}); + +describe("reducer — event ingestion", () => { + it("inserts a single event at the correct (newest-first) position", () => { + const s1 = reducer(initialState(), { + type: "event", + event: makeEvent({ id: "a", ts: "2026-04-16T00:00:00Z" }), + }); + const s2 = reducer(s1, { + type: "event", + event: makeEvent({ id: "b", ts: "2026-04-16T00:00:10Z" }), + }); + expect(s2.events.map((e) => e.id)).toEqual(["b", "a"]); + }); + + it("drops incoming events while paused", () => { + const paused = { ...initialState(), paused: true }; + const after = reducer(paused, { + type: "event", + event: makeEvent({ id: "x", ts: "2026-04-16T00:00:00Z" }), + }); + expect(after.events).toEqual([]); + expect(after).toBe(paused); + }); + + it("caps buffer at MAX_EVENTS", () => { + let s = initialState(); + for (let i = 0; i < MAX_EVENTS + 10; i++) { + s = reducer(s, { + type: "event", + event: makeEvent({ + id: `e${i}`, + ts: new Date(2026, 3, 16, 0, 0, i).toISOString(), + }), + }); + } + expect(s.events.length).toBe(MAX_EVENTS); + }); + + it("shifts selectedIdx forward when a newer event is inserted above it", () => { + let s = initialState(); + s = reducer(s, { + type: "event", + event: makeEvent({ id: "a", ts: "2026-04-16T00:00:00Z" }), + }); + s = { ...s, selectedIdx: 0 }; + s = reducer(s, { + type: "event", + event: makeEvent({ id: "b", ts: "2026-04-16T00:00:10Z" }), + }); + // b inserted at index 0, a moved to index 1, selection follows a + expect(s.events.map((e) => e.id)).toEqual(["b", "a"]); + expect(s.selectedIdx).toBe(1); + }); +}); + +describe("reducer — events-batch (backfill)", () => { + it("merges a batch into existing sorted buffer, newest-first", () => { + let s = initialState(); + s = reducer(s, { + type: "event", + event: makeEvent({ id: "z", ts: "2026-04-16T00:00:05Z" }), + }); + s = reducer(s, { + type: "events-batch", + events: [ + makeEvent({ id: "a", ts: "2026-04-16T00:00:00Z" }), + makeEvent({ id: "c", ts: "2026-04-16T00:00:10Z" }), + makeEvent({ id: "b", ts: "2026-04-16T00:00:03Z" }), + ], + }); + expect(s.events.map((e) => e.id)).toEqual(["c", "z", "b", "a"]); + }); + + it("ignores empty batches", () => { + const s0 = initialState(); + const s1 = reducer(s0, { type: "events-batch", events: [] }); + expect(s1).toBe(s0); + }); + + it("does not merge while paused", () => { + const s0 = { ...initialState(), paused: true }; + const s1 = reducer(s0, { + type: "events-batch", + events: [makeEvent({ id: "a", ts: "2026-04-16T00:00:00Z" })], + }); + expect(s1).toBe(s0); + }); + + it("caps merged buffer at MAX_EVENTS", () => { + const batch: AgentEvent[] = []; + for (let i = 0; i < MAX_EVENTS + 50; i++) { + batch.push( + makeEvent({ + id: `b${i}`, + ts: new Date(2026, 3, 16, 0, 0, i).toISOString(), + }), + ); + } + const s = reducer(initialState(), { type: "events-batch", events: batch }); + expect(s.events.length).toBe(MAX_EVENTS); + }); +}); + +describe("reducer — enrich", () => { + it("patches details on the matching event only", () => { + let s = initialState(); + s = reducer(s, { + type: "events-batch", + events: [ + makeEvent({ id: "a", ts: "2026-04-16T00:00:00Z" }), + makeEvent({ + id: "b", + ts: "2026-04-16T00:00:10Z", + details: { fullText: "original" }, + }), + ], + }); + const s2 = reducer(s, { + type: "enrich", + eventId: "b", + patch: { durationMs: 123 }, + }); + const b = s2.events.find((e) => e.id === "b")!; + expect(b.details?.durationMs).toBe(123); + expect(b.details?.fullText).toBe("original"); + const a = s2.events.find((e) => e.id === "a")!; + expect(a.details).toBeUndefined(); + }); + + it("is a no-op when the eventId is unknown", () => { + const s0 = initialState(); + const s1 = reducer(s0, { + type: "enrich", + eventId: "nope", + patch: { durationMs: 1 }, + }); + expect(s1).toBe(s0); + }); +}); + +describe("reducer — navigation (move + selectedIdx)", () => { + it("clamps move to [0, max-1]", () => { + const s = { ...initialState(), selectedIdx: null }; + const s1 = reducer(s, { type: "move", delta: 1, max: 10 }); + expect(s1.selectedIdx).toBe(0); + const s2 = reducer(s1, { type: "move", delta: 999, max: 10 }); + expect(s2.selectedIdx).toBe(9); + const s3 = reducer(s2, { type: "move", delta: -999, max: 10 }); + expect(s3.selectedIdx).toBe(0); + }); + + it("is a no-op when max is 0 (empty timeline)", () => { + const s0 = initialState(); + const s1 = reducer(s0, { type: "move", delta: 1, max: 0 }); + expect(s1).toBe(s0); + }); + + it("open-detail requires a selection", () => { + const s0 = initialState(); + const s1 = reducer(s0, { type: "open-detail" }); + expect(s1).toBe(s0); + + const s2 = reducer({ ...s0, selectedIdx: 0 }, { type: "open-detail" }); + expect(s2.detailOpen).toBe(true); + expect(s2.detailScroll).toBe(0); + }); +}); + +describe("reducer — cycle-filter", () => { + it("cycles through the agent list and wraps to null", () => { + const agents: Array<"claude-code" | "codex"> = ["claude-code", "codex"]; + let s = initialState(); + expect(s.filterAgent).toBeNull(); + s = reducer(s, { type: "cycle-filter", agents }); + expect(s.filterAgent).toBe("claude-code"); + s = reducer(s, { type: "cycle-filter", agents }); + expect(s.filterAgent).toBe("codex"); + s = reducer(s, { type: "cycle-filter", agents }); + expect(s.filterAgent).toBeNull(); + }); + + it("clears selection when the filter changes", () => { + const s = { ...initialState(), selectedIdx: 3 }; + const after = reducer(s, { type: "cycle-filter", agents: ["claude-code"] }); + expect(after.selectedIdx).toBeNull(); + }); +}); + +describe("reducer — home resets everything", () => { + it("wipes every active modal, filter, and scope in one action", () => { + const s: State = { + ...initialState(), + showHelp: true, + showPermissions: true, + detailOpen: true, + projectsOpen: true, + sessionsForProject: "foo", + projectFilter: "p", + sessionFilter: "s", + subAgentScope: "sub", + filterAgent: "claude-code", + searchQuery: "q", + searchOpen: true, + selectedIdx: 3, + detailScroll: 5, + permissionsScroll: 5, + sessionsScroll: 5, + }; + const after = reducer(s, { type: "home" }); + expect(after.showHelp).toBe(false); + expect(after.showPermissions).toBe(false); + expect(after.detailOpen).toBe(false); + expect(after.projectsOpen).toBe(false); + expect(after.sessionsForProject).toBeNull(); + expect(after.projectFilter).toBeNull(); + expect(after.sessionFilter).toBeNull(); + expect(after.subAgentScope).toBeNull(); + expect(after.filterAgent).toBeNull(); + expect(after.searchQuery).toBe(""); + expect(after.searchOpen).toBe(false); + expect(after.selectedIdx).toBeNull(); + expect(after.detailScroll).toBe(0); + expect(after.permissionsScroll).toBe(0); + expect(after.sessionsScroll).toBe(0); + }); +}); + +describe("reducer — back (esc escape hatch)", () => { + it("peels modals in the documented precedence order", () => { + const base = initialState(); + // help closes first + expect( + reducer({ ...base, showHelp: true, detailOpen: true }, { type: "back" }) + .showHelp, + ).toBe(false); + // detail before permissions + const s1 = reducer( + { ...base, detailOpen: true, showPermissions: true }, + { type: "back" }, + ); + expect(s1.detailOpen).toBe(false); + expect(s1.showPermissions).toBe(true); + // sessions closes and re-opens projects grid + const s2 = reducer( + { ...base, sessionsForProject: "p" }, + { type: "back" }, + ); + expect(s2.sessionsForProject).toBeNull(); + expect(s2.projectsOpen).toBe(true); + }); + + it("peels scope then filter layers in order", () => { + const base = initialState(); + const s1 = reducer( + { ...base, subAgentScope: "sub", sessionFilter: "sess" }, + { type: "back" }, + ); + expect(s1.subAgentScope).toBeNull(); + expect(s1.sessionFilter).toBe("sess"); // not yet popped + + const s2 = reducer(s1, { type: "back" }); + expect(s2.sessionFilter).toBeNull(); + }); + + it("finally clears selectedIdx and is a no-op on empty state", () => { + const base = initialState(); + const s1 = reducer({ ...base, selectedIdx: 5 }, { type: "back" }); + expect(s1.selectedIdx).toBeNull(); + const s2 = reducer(s1, { type: "back" }); + expect(s2).toBe(s1); + }); +}); + +describe("reducer — clear-filters", () => { + it("wipes all filters/scopes but keeps modals/overlays open", () => { + const s: State = { + ...initialState(), + projectFilter: "p", + sessionFilter: "s", + subAgentScope: "sub", + filterAgent: "claude-code", + searchQuery: "q", + detailOpen: true, + showHelp: true, + }; + const after = reducer(s, { type: "clear-filters" }); + expect(after.projectFilter).toBeNull(); + expect(after.sessionFilter).toBeNull(); + expect(after.subAgentScope).toBeNull(); + expect(after.filterAgent).toBeNull(); + expect(after.searchQuery).toBe(""); + expect(after.detailOpen).toBe(true); + expect(after.showHelp).toBe(true); + }); +}); + +describe("reducer — search overlay (unified search)", () => { + it("open resets query/hits and enters typing mode", () => { + const s = reducer(initialState(), { + type: "search-view-open", + mode: "semantic", + }); + expect(s.searchViewOpen).toBe(true); + expect(s.searchMode).toBe("semantic"); + expect(s.searchTyping).toBe(true); + expect(s.searchQ).toBe(""); + expect(s.searchHits).toEqual([]); + }); + + it("mode switch resets hits and re-enters typing", () => { + let s = reducer(initialState(), { type: "search-view-open" }); + s = reducer(s, { type: "search-view-type", char: "f" }); + s = reducer(s, { type: "search-view-type", char: "o" }); + s = reducer(s, { + type: "search-view-submit", + hits: [{ kind: "live", eventId: "e1", summary: "foo" } as never], + }); + expect(s.searchHits.length).toBe(1); + expect(s.searchTyping).toBe(false); + + const s2 = reducer(s, { type: "search-view-mode", mode: "cross" }); + expect(s2.searchMode).toBe("cross"); + expect(s2.searchHits).toEqual([]); + expect(s2.searchTyping).toBe(true); + }); + + it("type/backspace edits the query buffer", () => { + let s = reducer(initialState(), { type: "search-view-open" }); + s = apply( + s, + { type: "search-view-type", char: "a" }, + { type: "search-view-type", char: "b" }, + { type: "search-view-type", char: "c" }, + { type: "search-view-backspace" }, + ); + expect(s.searchQ).toBe("ab"); + }); + + it("move is clamped to hits length", () => { + let s = reducer(initialState(), { type: "search-view-open" }); + s = reducer(s, { + type: "search-view-submit", + hits: [ + { kind: "live", eventId: "1" } as never, + { kind: "live", eventId: "2" } as never, + { kind: "live", eventId: "3" } as never, + ], + }); + s = reducer(s, { type: "search-view-move", delta: 10 }); + expect(s.searchSelectedIdx).toBe(2); + s = reducer(s, { type: "search-view-move", delta: -10 }); + expect(s.searchSelectedIdx).toBe(0); + }); + + it("close wipes overlay state", () => { + let s = reducer(initialState(), { type: "search-view-open" }); + s = reducer(s, { type: "search-view-type", char: "x" }); + s = reducer(s, { type: "search-view-close" }); + expect(s.searchViewOpen).toBe(false); + expect(s.searchQ).toBe(""); + expect(s.searchHits).toEqual([]); + expect(s.searchTyping).toBe(false); + }); +}); + +describe("reducer — sub-agent + sessions scoping", () => { + it("scope-subagent clears selection and closes detail", () => { + const s0: State = { + ...initialState(), + selectedIdx: 5, + detailOpen: true, + }; + const s1 = reducer(s0, { type: "scope-subagent", subAgentId: "sub-a" }); + expect(s1.subAgentScope).toBe("sub-a"); + expect(s1.selectedIdx).toBeNull(); + expect(s1.detailOpen).toBe(false); + }); + + it("sessions-open-selected sets sessionFilter and closes the picker", () => { + const s0: State = { + ...initialState(), + sessionsForProject: "p", + selectedIdx: 2, + }; + const s1 = reducer(s0, { + type: "sessions-open-selected", + sessionId: "sess-123", + }); + expect(s1.sessionFilter).toBe("sess-123"); + expect(s1.sessionsForProject).toBeNull(); + expect(s1.selectedIdx).toBeNull(); + }); + + it("projects-select opens the sessions picker for that project", () => { + const s1 = reducer(initialState(), { + type: "projects-select", + name: "agentwatch", + }); + expect(s1.sessionsForProject).toBe("agentwatch"); + expect(s1.projectsOpen).toBe(false); + expect(s1.sessionsSelectedIdx).toBe(0); + }); +}); + +describe("reducer — anomaly-mark-notified", () => { + it("merges ids into the Set without mutating the previous state", () => { + const s0 = initialState(); + const s1 = reducer(s0, { + type: "anomaly-mark-notified", + ids: ["a", "b"], + }); + expect([...s1.anomalyNotified].sort()).toEqual(["a", "b"]); + expect(s0.anomalyNotified.size).toBe(0); // previous state untouched + + const s2 = reducer(s1, { + type: "anomaly-mark-notified", + ids: ["b", "c"], + }); + expect([...s2.anomalyNotified].sort()).toEqual(["a", "b", "c"]); + expect(s1.anomalyNotified.has("c")).toBe(false); + }); +}); + +describe("reducer — toggle-* overlays reset their selected index", () => { + it.each([ + ["toggle-compaction", "showCompaction", "compactionSelectedIdx"], + ["toggle-call-graph", "showCallGraph", "callGraphSelectedIdx"], + ["toggle-scheduled", "showScheduled", "scheduledSelectedIdx"], + ] as const)( + "%s flips visibility and resets the idx", + (type, visibleKey, idxKey) => { + const s0: State = { + ...initialState(), + [visibleKey]: true, + [idxKey]: 7, + }; + const s1 = reducer(s0, { type } as Action); + expect((s1 as unknown as Record<string, unknown>)[visibleKey]).toBe(false); + expect((s1 as unknown as Record<string, unknown>)[idxKey]).toBe(0); + }, + ); +}); + +describe("reducer — compound action traces (documented flows)", () => { + it("home → scope → search → back → back restores defaults", () => { + let s = initialState(); + s = reducer(s, { type: "scope-subagent", subAgentId: "sub" }); + s = reducer(s, { type: "search-view-open", mode: "live" }); + expect(s.subAgentScope).toBe("sub"); + expect(s.searchViewOpen).toBe(true); + + s = reducer(s, { type: "search-view-close" }); + expect(s.searchViewOpen).toBe(false); + expect(s.subAgentScope).toBe("sub"); // scope survives overlay close + + s = reducer(s, { type: "back" }); // peels sub-agent scope + expect(s.subAgentScope).toBeNull(); + }); + + it("paused buffer still accepts filter toggles and navigation", () => { + let s = { ...initialState(), paused: true }; + s = reducer(s, { type: "toggle-pause" }); + expect(s.paused).toBe(false); + s = reducer(s, { type: "toggle-pause" }); + expect(s.paused).toBe(true); + s = reducer(s, { type: "cycle-filter", agents: ["claude-code"] }); + expect(s.paused).toBe(true); + expect(s.filterAgent).toBe("claude-code"); + }); +}); diff --git a/src/ui/state.ts b/src/ui/state.ts new file mode 100644 index 0000000..a9ed7a1 --- /dev/null +++ b/src/ui/state.ts @@ -0,0 +1,535 @@ +import type { AgentEvent, AgentName, EventDetails } from "../schema.js"; +import type { SearchMode, UnifiedHit } from "./SearchView.js"; + +export const MAX_EVENTS = 500; + +export function matchesQuery(e: AgentEvent, q: string): boolean { + const needle = q.toLowerCase(); + if ((e.summary ?? "").toLowerCase().includes(needle)) return true; + if ((e.path ?? "").toLowerCase().includes(needle)) return true; + if ((e.cmd ?? "").toLowerCase().includes(needle)) return true; + if ((e.tool ?? "").toLowerCase().includes(needle)) return true; + if ((e.agent ?? "").toLowerCase().includes(needle)) return true; + const d = e.details; + if (d) { + if ((d.fullText ?? "").toLowerCase().includes(needle)) return true; + if ((d.thinking ?? "").toLowerCase().includes(needle)) return true; + } + return false; +} + +export function findInsertIdx(events: AgentEvent[], ts: string): number { + // Binary search for the first index whose ts is <= incoming ts. + // Events are sorted newest (largest ts) first. + let lo = 0; + let hi = events.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (events[mid]!.ts > ts) lo = mid + 1; + else hi = mid; + } + return lo; +} + +export type State = { + events: AgentEvent[]; + filterAgent: AgentName | null; + showAgents: boolean; + showPermissions: boolean; + paused: boolean; + /** Index into the *filtered* list; null = no selection */ + selectedIdx: number | null; + detailOpen: boolean; + detailScroll: number; + searchOpen: boolean; + searchQuery: string; + /** When set, timeline is scoped to events whose sessionId ends with + * `agent-<subAgentScope>` OR whose details.subAgentId === scope. */ + subAgentScope: string | null; + /** Projects-picker view state */ + projectsOpen: boolean; + projectsSelectedIdx: number; + /** Current project filter applied to the timeline */ + projectFilter: string | null; + /** Scroll offset for the permissions view */ + permissionsScroll: number; + /** Sessions-list view: project name when open, null when closed */ + sessionsForProject: string | null; + sessionsSelectedIdx: number; + sessionsScroll: number; + /** Scoped session filter (timeline shows only this sessionId) */ + sessionFilter: string | null; + /** Transient message shown at the footer for ~2s (e.g. after a yank). */ + flashMessage: string | null; + showHelp: boolean; + /** Token-attribution overlay for the currently scoped session. */ + showTokens: boolean; + /** Compaction visualizer overlay for the currently scoped session. */ + showCompaction: boolean; + compactionSelectedIdx: number; + /** Call-graph overlay for the currently scoped session. */ + showCallGraph: boolean; + callGraphSelectedIdx: number; + /** Scheduled-tasks overlay (cron + heartbeat). */ + showScheduled: boolean; + scheduledSelectedIdx: number; + /** Selected turn within the token-attribution view. */ + tokensSelectedIdx: number; + /** Unified search overlay. Replaces both the old `/` filter and the + * separate `?` cross-search. Mode tabs switch between live (in-buffer + * substring), cross-session (every jsonl on disk), and semantic + * (hybrid BM25 + embeddings). */ + searchViewOpen: boolean; + searchMode: SearchMode; + searchQ: string; + searchTyping: boolean; + searchHits: UnifiedHit[]; + searchSelectedIdx: number; + searchStatus: string | null; + searchConfirming: { query: string } | null; + /** Anomaly banner dismissal — keyed by a signature of current anomalies + * so re-flagging a different anomaly reopens the banner. */ + anomalyDismissKey: string | null; + /** Event IDs we have already fired desktop notifications for (per + * process lifetime). */ + anomalyNotified: Set<string>; +}; + +export type Action = + | { type: "event"; event: AgentEvent } + | { type: "events-batch"; events: AgentEvent[] } + | { type: "enrich"; eventId: string; patch: Partial<EventDetails> } + | { type: "toggle-agents" } + | { type: "toggle-permissions" } + | { type: "cycle-filter"; agents: AgentName[] } + | { type: "toggle-pause" } + | { type: "clear" } + | { type: "move"; delta: number; max: number } + | { type: "open-detail" } + | { type: "close-detail" } + | { type: "scroll-detail"; delta: number; max: number } + | { type: "open-search" } + | { type: "close-search" } + | { type: "confirm-search" } + | { type: "search-input"; char: string } + | { type: "search-backspace" } + | { type: "scope-subagent"; subAgentId: string } + | { type: "unscope-subagent" } + | { type: "toggle-projects" } + | { type: "projects-move"; delta: number; max: number } + | { type: "projects-select"; name: string } + | { type: "set-project-filter"; project: string | null } + | { type: "scroll-permissions"; delta: number; max: number } + | { type: "flash"; text: string } + | { type: "flash-clear" } + | { type: "toggle-help" } + | { type: "toggle-tokens" } + | { type: "toggle-compaction" } + | { type: "compaction-move"; delta: number; max: number } + | { type: "toggle-call-graph" } + | { type: "call-graph-move"; delta: number; max: number } + | { type: "toggle-scheduled" } + | { type: "scheduled-move"; delta: number; max: number } + | { type: "tokens-move"; delta: number; max: number } + | { type: "search-view-open"; mode?: SearchMode } + | { type: "search-view-close" } + | { type: "search-view-mode"; mode: SearchMode } + | { type: "search-view-type"; char: string } + | { type: "search-view-backspace" } + | { type: "search-view-submit"; hits: UnifiedHit[] } + | { type: "search-view-status"; status: string | null } + | { type: "search-view-confirm-start"; query: string } + | { type: "search-view-confirm-cancel" } + | { type: "search-view-move"; delta: number } + | { type: "anomaly-dismiss"; key: string } + | { type: "anomaly-mark-notified"; ids: string[] } + | { type: "home" } + | { type: "back" } + | { type: "open-sessions"; project: string } + | { type: "close-sessions" } + | { type: "sessions-move"; delta: number; max: number } + | { type: "sessions-scroll"; delta: number; max: number } + | { type: "sessions-open-selected"; sessionId: string } + | { type: "clear-filters" }; + +export function initialState(): State { + return { + events: [], + filterAgent: null, + showAgents: true, + showPermissions: false, + paused: false, + selectedIdx: null, + detailOpen: false, + detailScroll: 0, + searchOpen: false, + searchQuery: "", + subAgentScope: null, + projectsOpen: false, + projectsSelectedIdx: 0, + projectFilter: null, + permissionsScroll: 0, + sessionsForProject: null, + sessionsSelectedIdx: 0, + sessionsScroll: 0, + sessionFilter: null, + flashMessage: null, + showHelp: false, + showTokens: false, + searchViewOpen: false, + searchMode: "live", + searchQ: "", + searchTyping: false, + searchHits: [], + searchSelectedIdx: 0, + searchStatus: null, + searchConfirming: null, + anomalyDismissKey: null, + anomalyNotified: new Set<string>(), + showCompaction: false, + compactionSelectedIdx: 0, + tokensSelectedIdx: 0, + showCallGraph: false, + callGraphSelectedIdx: 0, + showScheduled: false, + scheduledSelectedIdx: 0, + }; +} + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case "event": { + if (state.paused) return state; + const next = state.events.slice(); + const idx = findInsertIdx(next, action.event.ts); + next.splice(idx, 0, action.event); + if (next.length > MAX_EVENTS) next.length = MAX_EVENTS; + let sel = state.selectedIdx; + if (sel !== null && idx <= sel) sel = sel + 1; + return { ...state, events: next, selectedIdx: sel }; + } + case "events-batch": { + if (state.paused || action.events.length === 0) return state; + // Merge-sort batch into the existing sorted buffer. O(n+m) rather + // than O(m log m) dispatches + O(n^2) inserts that the per-event + // path would do during backfill. + const merged: AgentEvent[] = []; + const a = state.events; // newest-first + const b = [...action.events].sort((x, y) => (x.ts < y.ts ? 1 : -1)); + let i = 0; + let j = 0; + while (i < a.length && j < b.length && merged.length < MAX_EVENTS) { + if (a[i]!.ts >= b[j]!.ts) merged.push(a[i++]!); + else merged.push(b[j++]!); + } + while (i < a.length && merged.length < MAX_EVENTS) merged.push(a[i++]!); + while (j < b.length && merged.length < MAX_EVENTS) merged.push(b[j++]!); + return { ...state, events: merged }; + } + case "enrich": { + const next = state.events.slice(); + for (let i = 0; i < next.length; i++) { + if (next[i]!.id !== action.eventId) continue; + const e = next[i]!; + next[i] = { + ...e, + details: { ...(e.details ?? {}), ...action.patch }, + }; + return { ...state, events: next }; + } + return state; + } + case "toggle-agents": + return { ...state, showAgents: !state.showAgents }; + case "toggle-permissions": + return { + ...state, + showPermissions: !state.showPermissions, + permissionsScroll: 0, + }; + case "cycle-filter": { + const idx = state.filterAgent + ? action.agents.indexOf(state.filterAgent) + : -1; + const next = + idx + 1 >= action.agents.length ? null : action.agents[idx + 1]; + return { ...state, filterAgent: next ?? null, selectedIdx: null }; + } + case "toggle-pause": + return { ...state, paused: !state.paused }; + case "clear": + return { ...state, events: [], selectedIdx: null }; + case "move": { + if (action.max <= 0) return state; + const cur = state.selectedIdx ?? -1; + const next = Math.max(0, Math.min(action.max - 1, cur + action.delta)); + return { ...state, selectedIdx: next }; + } + case "open-detail": + if (state.selectedIdx === null) return state; + return { ...state, detailOpen: true, detailScroll: 0 }; + case "close-detail": + return { ...state, detailOpen: false, detailScroll: 0 }; + case "scroll-detail": { + const next = Math.max(0, Math.min(action.max, state.detailScroll + action.delta)); + return { ...state, detailScroll: next }; + } + case "open-search": + return { ...state, searchOpen: true, selectedIdx: null }; + case "close-search": + return { ...state, searchOpen: false, searchQuery: "" }; + case "confirm-search": + return { ...state, searchOpen: false }; + case "search-input": + return { + ...state, + searchQuery: state.searchQuery + action.char, + selectedIdx: null, + }; + case "search-backspace": + return { + ...state, + searchQuery: state.searchQuery.slice(0, -1), + selectedIdx: null, + }; + case "scope-subagent": + return { + ...state, + subAgentScope: action.subAgentId, + selectedIdx: null, + detailOpen: false, + }; + case "unscope-subagent": + return { ...state, subAgentScope: null, selectedIdx: null }; + case "toggle-projects": + return { + ...state, + projectsOpen: !state.projectsOpen, + projectsSelectedIdx: 0, + detailOpen: false, + showPermissions: false, + }; + case "projects-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.projectsSelectedIdx + action.delta), + ); + return { ...state, projectsSelectedIdx: next }; + } + case "projects-select": + return { + ...state, + sessionsForProject: action.name, + sessionsSelectedIdx: 0, + sessionsScroll: 0, + projectsOpen: false, + }; + case "set-project-filter": + return { ...state, projectFilter: action.project, selectedIdx: null }; + case "scroll-permissions": { + const next = Math.max(0, Math.min(action.max, state.permissionsScroll + action.delta)); + return { ...state, permissionsScroll: next }; + } + case "open-sessions": + return { + ...state, + sessionsForProject: action.project, + sessionsSelectedIdx: 0, + sessionsScroll: 0, + }; + case "close-sessions": + return { ...state, sessionsForProject: null }; + case "sessions-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.sessionsSelectedIdx + action.delta), + ); + return { ...state, sessionsSelectedIdx: next }; + } + case "sessions-scroll": { + const next = Math.max(0, Math.min(action.max, state.sessionsScroll + action.delta)); + return { ...state, sessionsScroll: next }; + } + case "sessions-open-selected": + return { + ...state, + sessionFilter: action.sessionId, + sessionsForProject: null, + selectedIdx: null, + }; + case "flash": + return { ...state, flashMessage: action.text }; + case "flash-clear": + return { ...state, flashMessage: null }; + case "toggle-help": + return { ...state, showHelp: !state.showHelp }; + case "toggle-tokens": + return { ...state, showTokens: !state.showTokens }; + case "toggle-compaction": + return { + ...state, + showCompaction: !state.showCompaction, + compactionSelectedIdx: 0, + }; + case "compaction-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.compactionSelectedIdx + action.delta), + ); + return { ...state, compactionSelectedIdx: next }; + } + case "toggle-call-graph": + return { + ...state, + showCallGraph: !state.showCallGraph, + callGraphSelectedIdx: 0, + }; + case "call-graph-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.callGraphSelectedIdx + action.delta), + ); + return { ...state, callGraphSelectedIdx: next }; + } + case "toggle-scheduled": + return { + ...state, + showScheduled: !state.showScheduled, + scheduledSelectedIdx: 0, + }; + case "scheduled-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.scheduledSelectedIdx + action.delta), + ); + return { ...state, scheduledSelectedIdx: next }; + } + case "tokens-move": { + if (action.max <= 0) return state; + const next = Math.max( + 0, + Math.min(action.max - 1, state.tokensSelectedIdx + action.delta), + ); + return { ...state, tokensSelectedIdx: next }; + } + case "search-view-open": + return { + ...state, + searchViewOpen: true, + searchMode: action.mode ?? state.searchMode, + searchTyping: true, + searchQ: "", + searchHits: [], + searchSelectedIdx: 0, + searchStatus: null, + searchConfirming: null, + }; + case "search-view-close": + return { + ...state, + searchViewOpen: false, + searchTyping: false, + searchQ: "", + searchHits: [], + searchStatus: null, + searchConfirming: null, + }; + case "search-view-mode": + return { + ...state, + searchMode: action.mode, + searchHits: [], + searchSelectedIdx: 0, + searchStatus: null, + searchTyping: true, + }; + case "search-view-status": + return { ...state, searchStatus: action.status }; + case "search-view-confirm-start": + return { ...state, searchConfirming: { query: action.query } }; + case "search-view-confirm-cancel": + return { ...state, searchConfirming: null }; + case "search-view-type": + return { ...state, searchQ: state.searchQ + action.char }; + case "search-view-backspace": + return { ...state, searchQ: state.searchQ.slice(0, -1) }; + case "search-view-submit": + return { + ...state, + searchTyping: false, + searchHits: action.hits, + searchSelectedIdx: 0, + }; + case "search-view-move": { + const max = Math.max(1, state.searchHits.length); + const next = Math.max( + 0, + Math.min(max - 1, state.searchSelectedIdx + action.delta), + ); + return { ...state, searchSelectedIdx: next }; + } + case "anomaly-dismiss": + return { ...state, anomalyDismissKey: action.key }; + case "anomaly-mark-notified": { + const next = new Set(state.anomalyNotified); + for (const id of action.ids) next.add(id); + return { ...state, anomalyNotified: next }; + } + case "home": + return { + ...state, + showHelp: false, + showPermissions: false, + detailOpen: false, + projectsOpen: false, + sessionsForProject: null, + projectFilter: null, + sessionFilter: null, + subAgentScope: null, + filterAgent: null, + searchQuery: "", + searchOpen: false, + selectedIdx: null, + detailScroll: 0, + permissionsScroll: 0, + sessionsScroll: 0, + }; + case "clear-filters": + return { + ...state, + projectFilter: null, + sessionFilter: null, + subAgentScope: null, + filterAgent: null, + searchQuery: "", + selectedIdx: null, + }; + case "back": { + // esc semantics: close the deepest active modal / scope + if (state.showHelp) return { ...state, showHelp: false }; + if (state.detailOpen) return { ...state, detailOpen: false, detailScroll: 0 }; + if (state.showPermissions) + return { ...state, showPermissions: false, permissionsScroll: 0 }; + if (state.sessionsForProject) + return { ...state, sessionsForProject: null, projectsOpen: true }; + if (state.projectsOpen) return { ...state, projectsOpen: false }; + if (state.subAgentScope) + return { ...state, subAgentScope: null, selectedIdx: null }; + if (state.sessionFilter) + return { ...state, sessionFilter: null, selectedIdx: null }; + if (state.projectFilter) + return { ...state, projectFilter: null, selectedIdx: null }; + if (state.filterAgent) + return { ...state, filterAgent: null, selectedIdx: null }; + if (state.searchQuery) + return { ...state, searchQuery: "", selectedIdx: null }; + if (state.selectedIdx !== null) return { ...state, selectedIdx: null }; + return state; + } + } +} diff --git a/src/util/agent-call.test.ts b/src/util/agent-call.test.ts new file mode 100644 index 0000000..d3d1124 --- /dev/null +++ b/src/util/agent-call.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { detectAgentCall, tokenize } from "./agent-call.js"; + +describe("tokenize", () => { + it("handles plain whitespace splits", () => { + expect(tokenize("codex exec hello")).toEqual(["codex", "exec", "hello"]); + }); + + it("preserves quoted strings", () => { + expect(tokenize(`codex exec "hello world"`)).toEqual([ + "codex", + "exec", + "hello world", + ]); + expect(tokenize(`gemini -p 'short prompt'`)).toEqual([ + "gemini", + "-p", + "short prompt", + ]); + }); + + it("respects backslash escapes inside quotes", () => { + expect(tokenize(`x "a\\"b"`)).toEqual(["x", `a"b`]); + }); +}); + +describe("detectAgentCall — codex", () => { + it("matches `codex exec` with quoted prompt", () => { + expect(detectAgentCall(`codex exec "review my plan"`)).toEqual({ + callee: "codex", + kind: "exec", + prompt: "review my plan", + model: undefined, + }); + }); + + it("matches `codex chat` as kind=chat", () => { + expect(detectAgentCall("codex chat")).toMatchObject({ + callee: "codex", + kind: "chat", + }); + }); + + it("falls back to kind=unknown for unrecognised codex subcommands", () => { + expect(detectAgentCall("codex --help")).toMatchObject({ + callee: "codex", + kind: "unknown", + }); + }); + + it("strips an absolute path on the binary", () => { + expect( + detectAgentCall(`/opt/homebrew/bin/codex exec "what's wrong with my plan?"`), + ).toMatchObject({ + callee: "codex", + kind: "exec", + prompt: "what's wrong with my plan?", + }); + }); +}); + +describe("detectAgentCall — gemini", () => { + it("matches `gemini -p` and extracts the prompt flag", () => { + expect(detectAgentCall(`gemini -p "review my plan"`)).toEqual({ + callee: "gemini", + kind: "exec", + prompt: "review my plan", + model: undefined, + }); + }); + + it("matches `gemini --prompt=value` long form", () => { + expect(detectAgentCall(`gemini --prompt="hello"`)).toMatchObject({ + callee: "gemini", + prompt: "hello", + }); + }); + + it("matches `gemini` with a positional prompt fallback", () => { + expect(detectAgentCall(`gemini hi`)).toMatchObject({ + callee: "gemini", + prompt: "hi", + }); + }); +}); + +describe("detectAgentCall — claude", () => { + it("matches `claude exec`", () => { + expect(detectAgentCall(`claude exec "do the thing"`)).toMatchObject({ + callee: "claude-code", + kind: "exec", + prompt: "do the thing", + }); + }); +}); + +describe("detectAgentCall — ollama", () => { + it("captures the model and prompt for `ollama run`", () => { + expect(detectAgentCall(`ollama run llama3 "say hi"`)).toMatchObject({ + callee: "unknown", + kind: "exec", + model: "llama3", + prompt: "say hi", + }); + }); +}); + +describe("detectAgentCall — wrappers", () => { + it("strips `npx -y` wrapper", () => { + expect(detectAgentCall(`npx -y codex exec "foo"`)).toMatchObject({ + callee: "codex", + kind: "exec", + prompt: "foo", + }); + }); + + it("strips `pnpm dlx` wrapper", () => { + expect(detectAgentCall(`pnpm dlx gemini -p "bar"`)).toMatchObject({ + callee: "gemini", + prompt: "bar", + }); + }); + + it("strips `env FOO=bar` prefix and `nice`/`time`", () => { + expect(detectAgentCall(`time env FOO=1 codex exec "baz"`)).toMatchObject({ + callee: "codex", + prompt: "baz", + }); + }); + + it("strips nvm exec <version> wrapper", () => { + expect( + detectAgentCall(`nvm exec 22 codex exec "via nvm"`), + ).toMatchObject({ callee: "codex", prompt: "via nvm" }); + }); +}); + +describe("detectAgentCall — false positives", () => { + it("returns null for ordinary shell commands", () => { + expect(detectAgentCall("ls -la")).toBeNull(); + expect(detectAgentCall("git log --oneline")).toBeNull(); + expect(detectAgentCall("npm publish")).toBeNull(); + }); + + it("does not match commands that merely contain an agent name", () => { + // a script called codex-rs/build.sh is not codex itself + expect(detectAgentCall("./codex-rs/build.sh release")).toBeNull(); + // a grep for the word "codex" is not invoking codex + expect(detectAgentCall(`grep -r "codex" src/`)).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(detectAgentCall("")).toBeNull(); + expect(detectAgentCall(" ")).toBeNull(); + }); +}); diff --git a/src/util/agent-call.ts b/src/util/agent-call.ts new file mode 100644 index 0000000..61bb168 --- /dev/null +++ b/src/util/agent-call.ts @@ -0,0 +1,262 @@ +import type { AgentName } from "../schema.js"; + +/** + * Detects when an event represents one agent invoking another via the + * child agent's CLI. Today the most common pattern is Claude Code's + * `/council`-style flows that spawn `codex exec` and `gemini -p` + * subprocesses; this util lifts those opaque shell commands into + * structured agent-to-agent call metadata. + * + * Returns `null` for ordinary shell commands. The caller decides what + * to do with the structured result (typically: enrich the event so the + * call-graph view and OTel exporter can chain it to the spawned child + * session — see AUR-200, AUR-201, AUR-202). + */ + +export interface AgentCall { + callee: AgentName; + prompt?: string; + kind: "exec" | "chat" | "unknown"; + model?: string; +} + +interface PatternRule { + /** Match on the FIRST argv tokens (after the binary name). */ + binary: RegExp; + /** Optional sub-command tokens that gate the match. */ + subcommand?: string[]; + callee: AgentName; + kind: "exec" | "chat" | "unknown"; + /** Function that pulls the prompt from the parsed args. */ + promptFrom?: (args: string[]) => string | undefined; + /** Function that pulls a model id from the parsed args (e.g. ollama). */ + modelFrom?: (args: string[]) => string | undefined; +} + +/** When detecting we ignore the absolute path and look at the basename + * — `/usr/local/bin/codex exec foo` should match the same as `codex exec foo`. */ +function basename(token: string): string { + const i = token.lastIndexOf("/"); + return i === -1 ? token : token.slice(i + 1); +} + +/** Splits the full command into argv-ish tokens, respecting quotes. + * Not a true shell parser — handles the cases we actually see in + * agent log cmd strings (single + double quotes, escapes for the + * outermost layer). */ +export function tokenize(cmd: string): string[] { + const out: string[] = []; + let cur = ""; + let quote: '"' | "'" | null = null; + let i = 0; + while (i < cmd.length) { + const c = cmd[i]!; + if (quote) { + if (c === "\\" && i + 1 < cmd.length) { + cur += cmd[i + 1]; + i += 2; + continue; + } + if (c === quote) { + quote = null; + i += 1; + continue; + } + cur += c; + i += 1; + continue; + } + if (c === '"' || c === "'") { + quote = c; + i += 1; + continue; + } + if (c === " " || c === "\t") { + if (cur) { + out.push(cur); + cur = ""; + } + i += 1; + continue; + } + cur += c; + i += 1; + } + if (cur) out.push(cur); + return out; +} + +/** Find the value of `flag` in `args` accepting `-p value`, `--prompt value`, + * and `--prompt=value` shapes. */ +function flagValue(args: string[], short: string, long?: string): string | undefined { + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === short || a === long) { + return args[i + 1]; + } + if (long && a.startsWith(long + "=")) { + return a.slice(long.length + 1); + } + } + return undefined; +} + +/** First non-flag positional after the subcommand. Used as a fallback + * prompt source when no explicit `-p` flag is present. */ +function firstPositional(args: string[]): string | undefined { + for (const a of args) { + if (a.startsWith("-")) continue; + return a; + } + return undefined; +} + +const RULES: PatternRule[] = [ + // codex exec "<prompt>" — the canonical /council pattern. + { + binary: /^codex$/, + subcommand: ["exec"], + callee: "codex", + kind: "exec", + promptFrom: firstPositional, + }, + { + binary: /^codex$/, + subcommand: ["chat"], + callee: "codex", + kind: "chat", + }, + { + binary: /^codex$/, + callee: "codex", + kind: "unknown", + }, + // gemini -p "<prompt>" — the gemini CLI's exec mode. + { + binary: /^gemini$/, + callee: "gemini", + kind: "exec", + promptFrom: (args) => + flagValue(args, "-p", "--prompt") ?? firstPositional(args), + }, + // claude exec / npx claude — Claude Code's CLI invoked from another agent. + { + binary: /^claude$/, + subcommand: ["exec"], + callee: "claude-code", + kind: "exec", + promptFrom: firstPositional, + }, + { + binary: /^claude$/, + callee: "claude-code", + kind: "unknown", + }, + // aider <files-or-prompt> — usually run interactively but exec is possible. + { + binary: /^aider$/, + callee: "aider", + kind: "unknown", + promptFrom: (args) => flagValue(args, "-m", "--message"), + }, + // ollama run <model> [<prompt>] + // After the subcommand-strip pass, args is `[<model>, <prompt>?, ...flags]`. + { + binary: /^ollama$/, + subcommand: ["run"], + callee: "unknown", + kind: "exec", + modelFrom: (args) => args.find((a) => !a.startsWith("-")), + promptFrom: (args) => { + const positional = args.filter((a) => !a.startsWith("-")); + // First positional is the model; second (if any) is the prompt. + return positional[1]; + }, + }, +]; + +/** Try to detect an agent CLI invocation in a shell command string. + * Returns null when the cmd doesn't look like one we know. + * + * Handles common wrappers — `npx <agent>`, `bunx <agent>`, `pnpm dlx <agent>`, + * `nvm exec ... <agent>` — by stripping the wrapper before pattern matching. + * Also strips a leading `time` / `nice` / `nohup` / `env <KEY>=<VAL>...`. */ +export function detectAgentCall(cmd: string): AgentCall | null { + if (!cmd || !cmd.trim()) return null; + const tokens = tokenize(cmd.trim()); + if (tokens.length === 0) return null; + const stripped = stripWrappers(tokens); + if (stripped.length === 0) return null; + const binTok = basename(stripped[0]!); + const args = stripped.slice(1); + + for (const rule of RULES) { + if (!rule.binary.test(binTok)) continue; + let argsForExtract = args; + if (rule.subcommand && rule.subcommand.length > 0) { + const subIdx = args.findIndex((a) => !a.startsWith("-")); + const sub = subIdx >= 0 ? args[subIdx]! : undefined; + if (!sub || !rule.subcommand.includes(sub)) continue; + // Strip the matched subcommand so promptFrom / firstPositional + // don't return the subcommand itself. + argsForExtract = args.slice(0, subIdx).concat(args.slice(subIdx + 1)); + } + return { + callee: rule.callee, + kind: rule.kind, + prompt: rule.promptFrom?.(argsForExtract) ?? undefined, + model: rule.modelFrom?.(argsForExtract) ?? undefined, + }; + } + return null; +} + +/** Strip leading wrappers like `npx`, `bunx`, `pnpm dlx`, `nvm exec`, + * `env FOO=bar`, `time`, `nice`, `nohup`. Keeps everything from the + * first non-wrapper token onward. */ +function stripWrappers(tokens: string[]): string[] { + let i = 0; + while (i < tokens.length) { + const t = basename(tokens[i]!); + if (t === "time" || t === "nice" || t === "nohup") { + i += 1; + continue; + } + if (t === "env") { + i += 1; + while (i < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[i]!)) { + i += 1; + } + continue; + } + if (t === "npx" || t === "bunx" || t === "yarn" || t === "tsx") { + i += 1; + // Skip `-y` / `--yes` flags some users append to npx. + while (i < tokens.length && (tokens[i] === "-y" || tokens[i] === "--yes")) { + i += 1; + } + continue; + } + if (t === "pnpm") { + i += 1; + // Allow `pnpm dlx <pkg>` and `pnpm exec <pkg>`. + if (tokens[i] === "dlx" || tokens[i] === "exec") { + i += 1; + } + continue; + } + if (t === "nvm") { + i += 1; + if (tokens[i] === "exec" || tokens[i] === "run" || tokens[i] === "use") { + i += 1; + // Skip the optional <node-version> arg. + if (i < tokens.length && /^[\d.]+$/.test(tokens[i]!)) { + i += 1; + } + } + continue; + } + break; + } + return tokens.slice(i); +} diff --git a/src/util/anomaly.test.ts b/src/util/anomaly.test.ts new file mode 100644 index 0000000..4ce453a --- /dev/null +++ b/src/util/anomaly.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + detectStuckLoop, + eventSignature, + mad, + median, + robustZ, + scoreEvent, +} from "./anomaly.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: Math.random().toString(36).slice(2), + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "tool_call", + riskScore: 0, + ...o, +}); + +describe("median + mad", () => { + it("median handles odd and even lengths", () => { + expect(median([1, 2, 3, 4, 5])).toBe(3); + expect(median([1, 2, 3, 4])).toBe(2.5); + expect(median([])).toBe(0); + }); + + it("mad scales to be ≈ stddev for normal-ish data", () => { + // Symmetric data around 0 — MAD should be positive and non-zero. + const m = mad([-2, -1, 0, 1, 2]); + expect(m).toBeGreaterThan(0); + }); + + it("mad returns 0 for fewer than 2 points", () => { + expect(mad([])).toBe(0); + expect(mad([5])).toBe(0); + }); + + it("robustZ catches an outlier in heavy-tailed cost data", () => { + const history = [0.01, 0.02, 0.015, 0.03, 0.025, 0.02, 0.018]; + expect(robustZ(0.02, history)).toBeLessThan(1); + expect(robustZ(0.5, history)).toBeGreaterThan(10); + }); +}); + +describe("eventSignature", () => { + it("collapses numeric tails to N so same-shape commands collide", () => { + const a = evt({ cmd: "rm /tmp/abc123", tool: "Bash" }); + const b = evt({ cmd: "rm /tmp/xyz456", tool: "Bash" }); + expect(eventSignature(a)).toBe(eventSignature(b)); + }); +}); + +describe("detectStuckLoop", () => { + it("flags a run of ≥3 identical signatures (period 1)", () => { + const loop = Array.from({ length: 5 }, () => + evt({ tool: "Bash", cmd: "ls" }), + ); + const hit = detectStuckLoop(loop); + expect(hit?.period).toBe(1); + expect(hit?.count).toBeGreaterThanOrEqual(3); + }); + + it("flags an alternating A-B-A-B-A-B loop (period 2)", () => { + const loop = [ + evt({ tool: "Bash", cmd: "pytest" }), + evt({ tool: "Edit", path: "/a.py" }), + evt({ tool: "Bash", cmd: "pytest" }), + evt({ tool: "Edit", path: "/a.py" }), + evt({ tool: "Bash", cmd: "pytest" }), + evt({ tool: "Edit", path: "/a.py" }), + ]; + const hit = detectStuckLoop(loop); + expect(hit?.period).toBe(2); + }); + + it("does not flag distinct events", () => { + const mixed = [ + evt({ tool: "Bash", cmd: "ls" }), + evt({ tool: "Read", path: "/a" }), + evt({ tool: "Edit", path: "/b" }), + evt({ tool: "Bash", cmd: "pwd" }), + ]; + expect(detectStuckLoop(mixed)).toBeNull(); + }); +}); + +describe("scoreEvent", () => { + it("flags a cost outlier above |z| > 3.5", () => { + const history = Array.from({ length: 20 }, (_, i) => + evt({ details: { cost: 0.01 + (i % 3) * 0.002 } }), + ); + const outlier = evt({ details: { cost: 1.5 } }); + const flags = scoreEvent(outlier, history); + expect(flags.some((f) => f.kind === "cost")).toBe(true); + }); + + it("does not flag normal events", () => { + const history = Array.from({ length: 20 }, (_, i) => + evt({ details: { cost: 0.01 + (i % 5) * 0.001 } }), + ); + const normal = evt({ details: { cost: 0.012 } }); + expect(scoreEvent(normal, history)).toEqual([]); + }); + + it("requires minSamples of history before scoring", () => { + const history = [evt({ details: { cost: 0.01 } })]; + const maybeOutlier = evt({ details: { cost: 10 } }); + expect(scoreEvent(maybeOutlier, history)).toEqual([]); + }); +}); diff --git a/src/util/anomaly.ts b/src/util/anomaly.ts new file mode 100644 index 0000000..6be9709 --- /dev/null +++ b/src/util/anomaly.ts @@ -0,0 +1,292 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AgentEvent } from "../schema.js"; + +/** + * Local-only anomaly detection. Two detectors: + * 1. MAD z-score outliers on cost, duration, tokens (heavy-tailed → + * median + MAD is more robust than mean + stddev). Leys et al. 2013. + * 2. Rolling n-gram stuck-loop detector: flag when the same trigram of + * (tool, normalized_args_hash) repeats ≥3× in a 20-event window. + * + * Config: ~/.agentwatch/anomaly.json overrides thresholds. + */ + +export interface AnomalyThresholds { + /** |z| above this flags a metric outlier. Default 3.5. */ + zScore: number; + /** Size of the rolling window used by the stuck-loop detector. */ + loopWindow: number; + /** Min consecutive repeats of a trigram before flagging a stuck loop. */ + loopMinRepeats: number; + /** Minimum sample size before MAD scoring is considered reliable. */ + minSamples: number; +} + +export const DEFAULT_THRESHOLDS: AnomalyThresholds = { + zScore: 3.5, + loopWindow: 20, + loopMinRepeats: 3, + minSamples: 8, +}; + +export const ANOMALY_CONFIG_PATH = path.join( + os.homedir(), + ".agentwatch", + "anomaly.json", +); + +let cached: AnomalyThresholds | null = null; + +export function loadThresholds(): AnomalyThresholds { + if (cached) return cached; + try { + const raw = fs.readFileSync(ANOMALY_CONFIG_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial<AnomalyThresholds>; + cached = { ...DEFAULT_THRESHOLDS, ...parsed }; + } catch { + cached = DEFAULT_THRESHOLDS; + } + return cached; +} + +export function _resetAnomalyCache(): void { + cached = null; +} + +/* ---------- Stats helpers ---------- */ + +export function median(xs: number[]): number { + if (xs.length === 0) return 0; + const sorted = [...xs].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 0 + ? (sorted[mid - 1]! + sorted[mid]!) / 2 + : sorted[mid]!; +} + +/** Median Absolute Deviation with the 1.4826 scale so that for data drawn + * from a normal distribution MAD ≈ stddev. Returns 0 for <2 points. */ +export function mad(xs: number[]): number { + if (xs.length < 2) return 0; + const med = median(xs); + const deviations = xs.map((x) => Math.abs(x - med)); + return 1.4826 * median(deviations); +} + +export function robustZ(x: number, xs: number[]): number { + const m = median(xs); + const d = mad(xs); + if (d === 0) return 0; + return (x - m) / d; +} + +/* ---------- Stuck-loop detector ---------- */ + +/** Hash a tool_use by its name + normalized argument shape. We hash just + * the keys-and-string-prefixes, not the full values, so that repeated + * "Bash(rm -rf /tmp/a)" and "Bash(rm -rf /tmp/b)" collide — the latter + * is a classic agent-in-a-loop pattern. */ +export function eventSignature(e: AgentEvent): string { + const parts: string[] = [e.tool ?? e.type]; + if (e.cmd) parts.push(normalizeCmd(e.cmd)); + if (e.path) parts.push(e.path); + return parts.join("|"); +} + +function normalizeCmd(cmd: string): string { + // Collapse numeric tails (line numbers, tmp paths) that vary across + // otherwise-identical commands. + return cmd + .replace(/\b\d+\b/g, "N") + .replace(/\/tmp\/[A-Za-z0-9._-]+/g, "/tmp/X") + .slice(0, 120); +} + +/** Stuck-loop detector. Returns the pattern string and its repeat count + * when the last `loopWindow` events contain either: + * + * - ≥ `loopMinRepeats` consecutive identical signatures (A-A-A-…), or + * - ≥ `loopMinRepeats` repeats of a period-p cycle for p ∈ {2,3,4} + * (A-B-A-B-A-B, A-B-C-A-B-C, …). + * + * Alternating loops (p>1) are how agents fail most often in practice — + * "try X → fail → apologize → try X → fail" is a 2-cycle, not a + * consecutive repeat. */ +export function detectStuckLoop( + events: AgentEvent[], + thresholds: AnomalyThresholds = loadThresholds(), +): { pattern: string; count: number; period: number } | null { + const window = events.slice(-thresholds.loopWindow); + if (window.length < thresholds.loopMinRepeats) return null; + const sigs = window.map(eventSignature); + + // p = 1: consecutive identical signatures. + let best: { pattern: string; count: number; period: number } | null = null; + let run = 1; + for (let i = 1; i < sigs.length; i++) { + if (sigs[i] === sigs[i - 1]) { + run += 1; + if (run >= thresholds.loopMinRepeats) { + if (!best || run > best.count) { + best = { pattern: sigs[i]!, count: run, period: 1 }; + } + } + } else { + run = 1; + } + } + + // p ∈ {2,3,4}: sliding period check. For each period, count how many + // consecutive positions satisfy sigs[i] === sigs[i-p], then divide by + // p to count "full cycles". + for (let p = 2; p <= 4; p++) { + if (sigs.length < p * thresholds.loopMinRepeats) continue; + let consecutive = 0; + for (let i = p; i < sigs.length; i++) { + if (sigs[i] === sigs[i - p]) { + consecutive += 1; + const cycles = Math.floor(consecutive / p) + 1; + if (cycles >= thresholds.loopMinRepeats) { + const patternSigs = sigs.slice(i - consecutive, i - consecutive + p); + const pattern = patternSigs.join(" → "); + if (!best || cycles > best.count) { + best = { pattern, count: cycles, period: p }; + } + } + } else { + consecutive = 0; + } + } + } + return best; +} + +/* ---------- Event-level anomaly scoring ---------- */ + +export type AnomalyKind = "cost" | "duration" | "tokens" | "stuck-loop"; + +export interface AnomalyFlag { + kind: AnomalyKind; + /** Human-readable summary for the UI. */ + message: string; + /** |z| (for metric outliers) or repeat count (for loops). */ + magnitude: number; + /** Session this flag is attached to (for per-session aggregation). */ + sessionId?: string; +} + +export interface SessionAnomalySummary { + sessionId: string; + /** Counts per anomaly kind. */ + counts: Record<AnomalyKind, number>; + /** Highest magnitude seen (max |z| or longest loop). */ + worstMagnitude: number; + /** First-flag message to show in UI. */ + headline: string; +} + +/** Aggregate per-event flags into one summary row per session. */ +export function summarizeBySession( + perEvent: Map<string, AnomalyFlag[]>, +): SessionAnomalySummary[] { + const bySession = new Map<string, SessionAnomalySummary>(); + for (const flags of perEvent.values()) { + for (const f of flags) { + const sid = f.sessionId ?? "(unknown)"; + let row = bySession.get(sid); + if (!row) { + row = { + sessionId: sid, + counts: { cost: 0, duration: 0, tokens: 0, "stuck-loop": 0 }, + worstMagnitude: 0, + headline: f.message, + }; + bySession.set(sid, row); + } + row.counts[f.kind] += 1; + if (f.magnitude > row.worstMagnitude) { + row.worstMagnitude = f.magnitude; + row.headline = f.message; + } + } + } + return Array.from(bySession.values()).sort( + (a, b) => b.worstMagnitude - a.worstMagnitude, + ); +} + +/** Given an incoming event plus the history it should be scored against, + * return any anomaly flags that apply. Empty array means "normal". */ +export function scoreEvent( + event: AgentEvent, + history: AgentEvent[], + thresholds: AnomalyThresholds = loadThresholds(), +): AnomalyFlag[] { + const flags: AnomalyFlag[] = []; + + const costHistory = historyMetrics(history, (e) => e.details?.cost); + if ( + costHistory.length >= thresholds.minSamples && + event.details?.cost != null + ) { + const z = robustZ(event.details.cost, costHistory); + if (z > thresholds.zScore) { + flags.push({ + kind: "cost", + message: `cost ${z.toFixed(1)}× normal ($${event.details.cost.toFixed(4)})`, + magnitude: z, + sessionId: event.sessionId, + }); + } + } + + const durHistory = historyMetrics(history, (e) => e.details?.durationMs); + if ( + durHistory.length >= thresholds.minSamples && + event.details?.durationMs != null + ) { + const z = robustZ(event.details.durationMs, durHistory); + if (z > thresholds.zScore) { + flags.push({ + kind: "duration", + message: `duration ${z.toFixed(1)}× normal (${event.details.durationMs}ms)`, + magnitude: z, + sessionId: event.sessionId, + }); + } + } + + const tokHistory = historyMetrics(history, (e) => { + const u = e.details?.usage; + return u ? u.input + u.cacheCreate : undefined; + }); + if (tokHistory.length >= thresholds.minSamples && event.details?.usage) { + const total = + event.details.usage.input + event.details.usage.cacheCreate; + const z = robustZ(total, tokHistory); + if (z > thresholds.zScore) { + flags.push({ + kind: "tokens", + message: `tokens ${z.toFixed(1)}× normal (${total.toLocaleString()})`, + magnitude: z, + sessionId: event.sessionId, + }); + } + } + + return flags; +} + +function historyMetrics( + events: AgentEvent[], + pick: (e: AgentEvent) => number | undefined, +): number[] { + const out: number[] = []; + for (const e of events) { + const v = pick(e); + if (v != null && Number.isFinite(v)) out.push(v); + } + return out; +} diff --git a/src/util/budgets.test.ts b/src/util/budgets.test.ts new file mode 100644 index 0000000..0794cf1 --- /dev/null +++ b/src/util/budgets.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { computeBudgetStatus } from "./budgets.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: "x", + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "response", + riskScore: 0, + sessionId: "s1", + ...o, +}); + +describe("computeBudgetStatus", () => { + const now = new Date("2026-04-15T12:00:00Z"); + + it("aggregates per-session and per-day cost, flags no breach when under caps", () => { + const events: AgentEvent[] = [ + evt({ sessionId: "a", details: { cost: 1 }, ts: "2026-04-15T08:00:00Z" }), + evt({ sessionId: "a", details: { cost: 1 }, ts: "2026-04-15T09:00:00Z" }), + evt({ sessionId: "b", details: { cost: 0.5 }, ts: "2026-04-15T11:00:00Z" }), + ]; + const s = computeBudgetStatus( + events, + { perSessionUsd: 5, perDayUsd: 10 }, + now, + ); + expect(s.sessionCost).toBe(2); + expect(s.dayCost).toBeCloseTo(2.5); + expect(s.breachedSession).toBeUndefined(); + expect(s.dayBreach).toBe(false); + }); + + it("flags the breaching session when session cost exceeds cap", () => { + const events: AgentEvent[] = [ + evt({ sessionId: "a", details: { cost: 6 } }), + ]; + const s = computeBudgetStatus(events, { perSessionUsd: 5 }, now); + expect(s.breachedSession).toBe("a"); + }); + + it("flags day breach when total day cost exceeds cap", () => { + const events: AgentEvent[] = [ + evt({ details: { cost: 15 }, ts: "2026-04-15T09:00:00Z" }), + ]; + const s = computeBudgetStatus(events, { perDayUsd: 10 }, now); + expect(s.dayBreach).toBe(true); + }); + + it("excludes events from previous days from day total", () => { + const events: AgentEvent[] = [ + evt({ details: { cost: 100 }, ts: "2026-04-14T23:00:00Z" }), + evt({ details: { cost: 1 }, ts: "2026-04-15T10:00:00Z" }), + ]; + const s = computeBudgetStatus(events, { perDayUsd: 50 }, now); + expect(s.dayCost).toBe(1); + expect(s.dayBreach).toBe(false); + }); +}); diff --git a/src/util/budgets.ts b/src/util/budgets.ts new file mode 100644 index 0000000..f8c095c --- /dev/null +++ b/src/util/budgets.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AgentEvent } from "../schema.js"; + +/** + * Per-session and per-day cost ceilings. Spec lives in + * ~/.agentwatch/budgets.json: + * + * { "perSessionUsd": 5, "perDayUsd": 20 } + * + * When crossed, the header shows a red banner and the notifier fires + * once per crossing. We never kill agents — just shout. + */ + +export interface Budgets { + perSessionUsd?: number; + perDayUsd?: number; +} + +export const BUDGETS_PATH = path.join(os.homedir(), ".agentwatch", "budgets.json"); + +let cached: Budgets | null = null; + +export function loadBudgets(): Budgets { + if (cached !== null) return cached; + try { + const raw = fs.readFileSync(BUDGETS_PATH, "utf8"); + const parsed = JSON.parse(raw); + cached = { + perSessionUsd: + typeof parsed.perSessionUsd === "number" ? parsed.perSessionUsd : undefined, + perDayUsd: + typeof parsed.perDayUsd === "number" ? parsed.perDayUsd : undefined, + }; + } catch { + cached = {}; + } + return cached; +} + +export function _resetBudgetsCache(): void { + cached = null; +} + +export interface BudgetStatus { + sessionCost: number; + dayCost: number; + perSessionUsd?: number; + perDayUsd?: number; + /** Highest session id breaching its cap. */ + breachedSession?: string; + dayBreach: boolean; +} + +/** Compute per-session and per-day aggregate costs across the event + * buffer and flag the first breaching session (if any). */ +export function computeBudgetStatus( + events: AgentEvent[], + budgets: Budgets = loadBudgets(), + now: Date = new Date(), +): BudgetStatus { + const todayStart = new Date(now); + todayStart.setUTCHours(0, 0, 0, 0); + const todayMs = todayStart.getTime(); + + let dayCost = 0; + let maxSession = { id: "", cost: 0 }; + const perSession = new Map<string, number>(); + + for (const e of events) { + const c = e.details?.cost ?? 0; + if (c <= 0) continue; + const sid = e.sessionId ?? ""; + const sCost = (perSession.get(sid) ?? 0) + c; + perSession.set(sid, sCost); + if (sCost > maxSession.cost) maxSession = { id: sid, cost: sCost }; + const t = new Date(e.ts).getTime(); + if (t >= todayMs) dayCost += c; + } + + const status: BudgetStatus = { + sessionCost: maxSession.cost, + dayCost, + perSessionUsd: budgets.perSessionUsd, + perDayUsd: budgets.perDayUsd, + dayBreach: + budgets.perDayUsd != null && dayCost > budgets.perDayUsd, + }; + if ( + budgets.perSessionUsd != null && + maxSession.cost > budgets.perSessionUsd + ) { + status.breachedSession = maxSession.id || "(unknown)"; + } + return status; +} diff --git a/src/util/call-graph.test.ts b/src/util/call-graph.test.ts new file mode 100644 index 0000000..eccf1ab --- /dev/null +++ b/src/util/call-graph.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + aggregateSubtree, + buildCallGraph, + flatten, +} from "./call-graph.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: Math.random().toString(36).slice(2), + ts: o.ts ?? "2026-04-16T10:00:00Z", + agent: "claude-code", + type: "response", + riskScore: 0, + ...o, +}); + +describe("buildCallGraph", () => { + it("returns null when the root session has no events", () => { + expect(buildCallGraph([], "missing")).toBeNull(); + }); + + it("builds a single-node tree for a session with no agent_calls", () => { + const events = [ + evt({ id: "e1", sessionId: "s1", ts: "2026-04-16T10:00:00Z" }), + evt({ id: "e2", sessionId: "s1", ts: "2026-04-16T10:00:05Z" }), + ]; + const tree = buildCallGraph(events, "s1"); + expect(tree?.kind).toBe("session"); + expect(tree?.events).toBe(2); + expect(tree?.children).toHaveLength(0); + }); + + it("attaches a call child when an event has details.agentCall", () => { + const events = [ + evt({ + id: "claudeBash", + sessionId: "s1", + type: "shell_exec", + details: { agentCall: { callee: "codex", kind: "exec", prompt: "review" } }, + }), + ]; + const tree = buildCallGraph(events, "s1"); + expect(tree?.children).toHaveLength(1); + expect(tree!.children[0]!.kind).toBe("call"); + expect(tree!.children[0]!.callee).toBe("codex"); + }); + + it("links a spawned child session under its parent call event", () => { + const events: AgentEvent[] = [ + evt({ + id: "claudeBash", + sessionId: "s1", + agent: "claude-code", + type: "shell_exec", + ts: "2026-04-16T10:00:00Z", + details: { + agentCall: { callee: "codex", kind: "exec", prompt: "review" }, + }, + }), + evt({ + id: "codexFirst", + sessionId: "s2", + agent: "codex", + type: "prompt", + ts: "2026-04-16T10:00:02Z", + details: { parentSpawnId: "claudeBash" }, + }), + evt({ + id: "codexSecond", + sessionId: "s2", + agent: "codex", + type: "response", + ts: "2026-04-16T10:00:05Z", + details: { usage: { input: 100, cacheCreate: 0, cacheRead: 0, output: 30 }, cost: 0.01 }, + }), + ]; + const tree = buildCallGraph(events, "s1"); + const callNode = tree!.children[0]!; + expect(callNode.children).toHaveLength(1); + const codexSession = callNode.children[0]!; + expect(codexSession.kind).toBe("session"); + expect(codexSession.agent).toBe("codex"); + expect(codexSession.sessionId).toBe("s2"); + expect(codexSession.events).toBe(2); + expect(codexSession.cost).toBeCloseTo(0.01); + }); + + it("recurses through nested agent_calls", () => { + const events: AgentEvent[] = [ + evt({ id: "a1", sessionId: "s1", agent: "claude-code", ts: "10:00:00Z", + type: "shell_exec", + details: { agentCall: { callee: "codex", kind: "exec" } } }), + evt({ id: "b1", sessionId: "s2", agent: "codex", ts: "10:00:01Z", + type: "shell_exec", + details: { parentSpawnId: "a1", agentCall: { callee: "gemini", kind: "exec" } } }), + evt({ id: "c1", sessionId: "s3", agent: "gemini", ts: "10:00:02Z", + type: "prompt", + details: { parentSpawnId: "b1" } }), + ]; + const tree = buildCallGraph(events, "s1"); + expect(tree!.children[0]!.callee).toBe("codex"); + const codexSession = tree!.children[0]!.children[0]!; + expect(codexSession.children[0]!.callee).toBe("gemini"); + }); +}); + +describe("aggregateSubtree", () => { + it("sums cost and tokens across the whole subtree", () => { + const events: AgentEvent[] = [ + evt({ id: "a1", sessionId: "s1", type: "response", + details: { usage: { input: 50, cacheCreate: 0, cacheRead: 0, output: 10 }, cost: 0.005 } }), + evt({ id: "a2", sessionId: "s1", type: "shell_exec", + details: { cost: 0.001, agentCall: { callee: "codex", kind: "exec" } } }), + evt({ id: "b1", sessionId: "s2", agent: "codex", + details: { parentSpawnId: "a2", usage: { input: 200, cacheCreate: 0, cacheRead: 0, output: 50 }, cost: 0.02 } }), + ]; + const tree = buildCallGraph(events, "s1")!; + const agg = aggregateSubtree(tree); + expect(agg.totalCost).toBeCloseTo(0.026); + expect(agg.totalInput).toBe(250); + expect(agg.totalOutput).toBe(60); + expect(agg.agents.has("claude-code")).toBe(true); + expect(agg.agents.has("codex")).toBe(true); + }); +}); + +describe("flatten", () => { + it("produces an in-order list with depth + isLast info", () => { + const events: AgentEvent[] = [ + evt({ id: "a1", sessionId: "s1", type: "shell_exec", + details: { agentCall: { callee: "codex", kind: "exec" } } }), + evt({ id: "a2", sessionId: "s1", type: "shell_exec", + details: { agentCall: { callee: "gemini", kind: "exec" } } }), + ]; + const tree = buildCallGraph(events, "s1")!; + const flat = flatten(tree); + expect(flat).toHaveLength(3); // root + 2 calls + expect(flat[2]!.isLast).toBe(true); + }); +}); diff --git a/src/util/call-graph.ts b/src/util/call-graph.ts new file mode 100644 index 0000000..009dccb --- /dev/null +++ b/src/util/call-graph.ts @@ -0,0 +1,236 @@ +import type { AgentEvent, AgentName } from "../schema.js"; + +/** + * Build a tree representing inter-agent calls rooted at a single session. + * + * session(claude-code, sess-A) + * ├─ prompt + response turns inside sess-A + * ├─ call → codex "review my plan" + * │ └─ session(codex, sess-B) + * │ ├─ events inside sess-B + * │ └─ call → … (recursive) + * └─ call → gemini "second opinion" + * └─ session(gemini, sess-C) + * └─ events inside sess-C + * + * Linking: + * - A `call` node corresponds to an event in the parent session that + * has `details.agentCall` (set by the Claude adapter via AUR-199). + * - A `session` node under a `call` is found by scanning the full + * event buffer for any event whose `details.parentSpawnId === call.id` + * (set by the Codex/Gemini adapters via AUR-200), then grouping + * those events by their `sessionId`. + */ + +export interface CallGraphNode { + /** "session" = an agent's session scope; "call" = a Bash(<agent>) + * invocation inside a parent session. */ + kind: "session" | "call"; + /** For session nodes: which agent + which session. */ + agent?: AgentName; + sessionId?: string; + /** For call nodes: callee + extracted prompt. */ + callee?: AgentName; + prompt?: string; + /** The originating event id (the call event itself, or the first + * event of the session). Used as React key + Enter target. */ + eventId: string; + /** Wall-clock ms when this scope started. */ + startMs: number; + /** Aggregate metrics for this scope (and only this scope, not + * including descendants). */ + cost: number; + inputTokens: number; + outputTokens: number; + events: number; + children: CallGraphNode[]; +} + +interface BuildOpts { + /** Maximum recursion depth (defensive — pathological loops shouldn't + * blow the stack). */ + maxDepth?: number; +} + +/** Build the call graph for a root session, walking down all spawned + * child sessions transitively. */ +export function buildCallGraph( + allEvents: AgentEvent[], + rootSessionId: string, + opts: BuildOpts = {}, +): CallGraphNode | null { + const maxDepth = opts.maxDepth ?? 8; + const eventsBySession = groupBySession(allEvents); + const sessionByParentId = indexByParentSpawnId(allEvents); + return buildSessionNode( + rootSessionId, + eventsBySession, + sessionByParentId, + maxDepth, + 0, + ); +} + +function buildSessionNode( + sessionId: string, + bySession: Map<string, AgentEvent[]>, + byParent: Map<string, AgentEvent[]>, + maxDepth: number, + depth: number, +): CallGraphNode | null { + const sessionEvents = bySession.get(sessionId); + if (!sessionEvents || sessionEvents.length === 0) return null; + const sorted = [...sessionEvents].sort((a, b) => + a.ts < b.ts ? -1 : 1, + ); + const first = sorted[0]!; + const node: CallGraphNode = { + kind: "session", + agent: first.agent, + sessionId, + eventId: first.id, + startMs: new Date(first.ts).getTime(), + cost: 0, + inputTokens: 0, + outputTokens: 0, + events: sorted.length, + children: [], + }; + for (const e of sorted) { + accumulateMetrics(node, e); + if (e.details?.agentCall) { + const callNode = buildCallNode( + e, + bySession, + byParent, + maxDepth, + depth, + ); + if (callNode) node.children.push(callNode); + } + } + return node; +} + +function buildCallNode( + callEvent: AgentEvent, + bySession: Map<string, AgentEvent[]>, + byParent: Map<string, AgentEvent[]>, + maxDepth: number, + depth: number, +): CallGraphNode { + const ac = callEvent.details!.agentCall!; + const node: CallGraphNode = { + kind: "call", + callee: ac.callee, + prompt: ac.prompt, + eventId: callEvent.id, + startMs: new Date(callEvent.ts).getTime(), + cost: callEvent.details?.cost ?? 0, + inputTokens: callEvent.details?.usage?.input ?? 0, + outputTokens: callEvent.details?.usage?.output ?? 0, + events: 1, + children: [], + }; + if (depth >= maxDepth) return node; + // Find the spawned child session(s). A single call typically spawns + // one child but defensive: a buggy adapter could double-link. + const spawned = byParent.get(callEvent.id) ?? []; + const childSessionIds = new Set<string>(); + for (const e of spawned) { + if (e.sessionId) childSessionIds.add(e.sessionId); + } + for (const sid of childSessionIds) { + const sessionNode = buildSessionNode( + sid, + bySession, + byParent, + maxDepth, + depth + 1, + ); + if (sessionNode) node.children.push(sessionNode); + } + return node; +} + +function accumulateMetrics(node: CallGraphNode, e: AgentEvent): void { + if (e.details?.cost) node.cost += e.details.cost; + if (e.details?.usage) { + node.inputTokens += e.details.usage.input; + node.outputTokens += e.details.usage.output; + } +} + +function groupBySession(events: AgentEvent[]): Map<string, AgentEvent[]> { + const m = new Map<string, AgentEvent[]>(); + for (const e of events) { + if (!e.sessionId) continue; + let arr = m.get(e.sessionId); + if (!arr) { + arr = []; + m.set(e.sessionId, arr); + } + arr.push(e); + } + return m; +} + +function indexByParentSpawnId(events: AgentEvent[]): Map<string, AgentEvent[]> { + const m = new Map<string, AgentEvent[]>(); + for (const e of events) { + const pid = e.details?.parentSpawnId; + if (!pid) continue; + let arr = m.get(pid); + if (!arr) { + arr = []; + m.set(pid, arr); + } + arr.push(e); + } + return m; +} + +/** Sum cost / tokens across the whole subtree rooted here. */ +export function aggregateSubtree(node: CallGraphNode): { + totalCost: number; + totalInput: number; + totalOutput: number; + totalEvents: number; + agents: Set<AgentName>; +} { + let totalCost = node.cost; + let totalInput = node.inputTokens; + let totalOutput = node.outputTokens; + let totalEvents = node.events; + const agents = new Set<AgentName>(); + if (node.agent) agents.add(node.agent); + if (node.callee) agents.add(node.callee); + for (const child of node.children) { + const sub = aggregateSubtree(child); + totalCost += sub.totalCost; + totalInput += sub.totalInput; + totalOutput += sub.totalOutput; + totalEvents += sub.totalEvents; + for (const a of sub.agents) agents.add(a); + } + return { totalCost, totalInput, totalOutput, totalEvents, agents }; +} + +/** Flatten the tree into an ordered list of (depth, node) pairs for + * rendering as a single scrollable list. */ +export function flatten( + node: CallGraphNode, + depth = 0, +): Array<{ depth: number; node: CallGraphNode; isLast: boolean }> { + const out: Array<{ depth: number; node: CallGraphNode; isLast: boolean }> = []; + out.push({ depth, node, isLast: false }); + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]!; + const sub = flatten(child, depth + 1); + if (i === node.children.length - 1) { + sub[0]!.isLast = true; + } + out.push(...sub); + } + return out; +} diff --git a/src/util/clipboard.ts b/src/util/clipboard.ts new file mode 100644 index 0000000..688d299 --- /dev/null +++ b/src/util/clipboard.ts @@ -0,0 +1,71 @@ +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; + +export type ClipboardResult = + | { ok: true } + | { ok: false; reason: string }; + +/** Copy text to the system clipboard. Zero-dependency: shells out to the + * platform-native tool. Returns {ok:false, reason} if unavailable so the + * caller can surface a helpful hint instead of crashing. */ +export function copyToClipboard(text: string): ClipboardResult { + const os = platform(); + try { + if (os === "darwin") { + return run("pbcopy", [], text); + } + if (os === "linux") { + // Prefer Wayland, fall back to xclip, then xsel. + if (commandExists("wl-copy")) return run("wl-copy", [], text); + if (commandExists("xclip")) return run("xclip", ["-selection", "clipboard"], text); + if (commandExists("xsel")) return run("xsel", ["--clipboard", "--input"], text); + return { + ok: false, + reason: "install wl-copy / xclip / xsel for clipboard support", + }; + } + if (os === "win32") { + return run("clip", [], text); + } + return { ok: false, reason: `clipboard not supported on ${os}` }; + } catch (err) { + return { ok: false, reason: String(err) }; + } +} + +function run(cmd: string, args: string[], input: string): ClipboardResult { + // Explicit stdio: Ink puts stdin into raw mode so the default fd + // inheritance can EBADF on spawnSync. Pipe stdin (we're supplying + // `input`), ignore the child's stdout/stderr entirely. + const res = spawnSync(cmd, args, { + input, + stdio: ["pipe", "ignore", "ignore"], + }); + if (res.error) return { ok: false, reason: String(res.error) }; + if (res.status !== 0) + return { ok: false, reason: `${cmd} exited ${res.status}` }; + return { ok: true }; +} + +function commandExists(cmd: string): boolean { + const res = spawnSync("sh", ["-c", `command -v ${cmd}`], { + stdio: ["ignore", "ignore", "ignore"], + }); + return res.status === 0; +} + +/** Pick the most-useful text to yank for a given event type. */ +export function eventToYankText( + summary?: string, + path?: string, + cmd?: string, + toolResult?: string, + fullText?: string, +): string { + // Priority: tool output > full text > cmd > path > summary + if (toolResult && toolResult.trim()) return toolResult; + if (fullText && fullText.trim()) return fullText; + if (cmd) return cmd; + if (path) return path; + return summary ?? ""; +} diff --git a/src/util/codex-permissions.ts b/src/util/codex-permissions.ts new file mode 100644 index 0000000..32b0a93 --- /dev/null +++ b/src/util/codex-permissions.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Codex permission surface. Two sources: + * ~/.codex/config.toml — per-project trust_level + global overrides + * latest rollout session → turn_context.sandbox_policy + approval_policy + * + * The config is a thin TOML file (we do a best-effort regex-based parse + * rather than pulling in a full TOML dependency — the shape is small and + * stable). Sandbox policy info comes from the most recent session so we + * can show what the agent is actually running with, not just what's in + * the config file. + */ + +export interface CodexProjectTrust { + cwd: string; + trustLevel: string; +} + +export interface CodexPermissions { + configPath: string; + projects: CodexProjectTrust[]; + sandboxPolicy?: string; + writableRoots?: string[]; + networkAccess?: boolean; + approvalPolicy?: string; + model?: string; + present: boolean; +} + +export function readCodexPermissions(home: string = os.homedir()): CodexPermissions { + const configPath = path.join(home, ".codex", "config.toml"); + const base: CodexPermissions = { + configPath, + projects: [], + present: false, + }; + if (!fs.existsSync(configPath)) return base; + base.present = true; + try { + const text = fs.readFileSync(configPath, "utf8"); + base.projects = parseProjectsToml(text); + } catch { + /* unreadable config */ + } + // Augment with the latest session's sandbox_policy. + const latest = findLatestSession(home); + if (latest) { + try { + const raw = fs.readFileSync(latest, "utf8"); + const lines = raw.split("\n"); + for (const line of lines) { + if (!line) continue; + try { + const obj = JSON.parse(line); + if (obj.type === "turn_context") { + const p = obj.payload ?? {}; + if (p.sandbox_policy) { + const sp = p.sandbox_policy; + base.sandboxPolicy = typeof sp === "object" && sp ? String(sp.type ?? "?") : String(sp); + base.writableRoots = + Array.isArray(sp?.writable_roots) ? sp.writable_roots : []; + base.networkAccess = Boolean(sp?.network_access); + } + if (typeof p.approval_policy === "string") { + base.approvalPolicy = p.approval_policy; + } + if (typeof p.model === "string") base.model = p.model; + } + } catch { + /* malformed line */ + } + } + } catch { + /* unreadable session */ + } + } + return base; +} + +function parseProjectsToml(text: string): CodexProjectTrust[] { + const out: CodexProjectTrust[] = []; + const sectionRe = /\[projects\."([^"]+)"\]([\s\S]*?)(?=\n\[|$)/g; + let m: RegExpExecArray | null; + while ((m = sectionRe.exec(text)) !== null) { + const cwd = m[1]!; + const body = m[2]!; + const trustRe = /trust_level\s*=\s*"([^"]+)"/; + const mt = trustRe.exec(body); + out.push({ cwd, trustLevel: mt ? mt[1]! : "?" }); + } + return out; +} + +function findLatestSession(home: string): string | null { + const root = path.join(home, ".codex", "sessions"); + type Best = { path: string; mtime: number }; + let best: Best | null = null; + const walk = (dir: string, depth: number): void => { + if (depth > 5) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, depth + 1); + else if (e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) { + try { + const st = fs.statSync(full); + if (!best || st.mtimeMs > best.mtime) { + best = { path: full, mtime: st.mtimeMs }; + } + } catch { + /* unreadable */ + } + } + } + }; + walk(root, 0); + return best ? (best as Best).path : null; +} diff --git a/src/util/compaction.test.ts b/src/util/compaction.test.ts new file mode 100644 index 0000000..418458c --- /dev/null +++ b/src/util/compaction.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + buildCompactionSeries, + renderCompactionBar, + contextWindow, +} from "./compaction.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: Math.random().toString(36).slice(2), + ts: o.ts ?? "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "response", + riskScore: 0, + sessionId: "s1", + ...o, +}); + +describe("contextWindow", () => { + it("defaults to 200k", () => { + delete process.env.AGENTWATCH_CONTEXT_WINDOW; + expect(contextWindow()).toBe(200_000); + }); + + it("respects the env override", () => { + process.env.AGENTWATCH_CONTEXT_WINDOW = "1000000"; + expect(contextWindow()).toBe(1_000_000); + delete process.env.AGENTWATCH_CONTEXT_WINDOW; + }); +}); + +describe("buildCompactionSeries", () => { + it("produces one turn per assistant turn with usage data", () => { + const events: AgentEvent[] = [ + evt({ + ts: "2026-04-15T10:00:00Z", + details: { + usage: { input: 1000, cacheRead: 50_000, cacheCreate: 0, output: 100 }, + }, + }), + evt({ + ts: "2026-04-15T10:01:00Z", + details: { + usage: { input: 2000, cacheRead: 100_000, cacheCreate: 0, output: 200 }, + }, + }), + ]; + const series = buildCompactionSeries(events, "s1", 200_000); + expect(series.points).toHaveLength(2); + expect(series.points[0]!.kind).toBe("turn"); + expect(series.points[0]!.fillBefore).toBeCloseTo(51_000 / 200_000); + expect(series.points[1]!.fillBefore).toBeCloseTo(102_000 / 200_000); + expect(series.compactionCount).toBe(0); + }); + + it("records compaction events with before / after fills", () => { + const events: AgentEvent[] = [ + evt({ + ts: "2026-04-15T10:00:00Z", + details: { + usage: { input: 180_000, cacheRead: 0, cacheCreate: 0, output: 200 }, + }, + }), + evt({ ts: "2026-04-15T10:00:30Z", type: "compaction" }), + evt({ + ts: "2026-04-15T10:01:00Z", + details: { + usage: { input: 5_000, cacheRead: 0, cacheCreate: 0, output: 100 }, + }, + }), + ]; + const series = buildCompactionSeries(events, "s1", 200_000); + expect(series.compactionCount).toBe(1); + const compact = series.points.find((p) => p.kind === "compaction")!; + expect(compact.fillBefore).toBeCloseTo(0.9); + expect(compact.fillAfter).toBe(0); + expect(series.points[series.points.length - 1]!.fillBefore).toBeCloseTo(0.025); + }); + + it("ignores events from other sessions", () => { + const events: AgentEvent[] = [ + evt({ + sessionId: "other", + details: { + usage: { input: 100_000, cacheRead: 0, cacheCreate: 0, output: 0 }, + }, + }), + ]; + expect(buildCompactionSeries(events, "s1").points).toHaveLength(0); + }); +}); + +describe("renderCompactionBar", () => { + it("produces block characters whose density tracks fill %", () => { + const events: AgentEvent[] = [ + evt({ + ts: "2026-04-15T10:00:00Z", + details: { + usage: { input: 20_000, cacheRead: 0, cacheCreate: 0, output: 0 }, + }, + }), + evt({ + ts: "2026-04-15T10:01:00Z", + details: { + usage: { input: 180_000, cacheRead: 0, cacheCreate: 0, output: 0 }, + }, + }), + ]; + const series = buildCompactionSeries(events, "s1", 200_000); + const bar = renderCompactionBar(series, 80); + expect(bar.length).toBe(2); + // The second turn is much fuller than the first, so its char ord + // must be strictly higher in the block sequence. + expect(bar.charCodeAt(1)).toBeGreaterThan(bar.charCodeAt(0)); + }); + + it("shows ⋈ for a compaction marker", () => { + const events: AgentEvent[] = [ + evt({ + details: { + usage: { input: 180_000, cacheRead: 0, cacheCreate: 0, output: 0 }, + }, + }), + evt({ ts: "2026-04-15T10:01:00Z", type: "compaction" }), + ]; + const series = buildCompactionSeries(events, "s1"); + expect(renderCompactionBar(series, 80)).toContain("⋈"); + }); +}); diff --git a/src/util/compaction.ts b/src/util/compaction.ts new file mode 100644 index 0000000..e325969 --- /dev/null +++ b/src/util/compaction.ts @@ -0,0 +1,128 @@ +import type { AgentEvent } from "../schema.js"; + +/** + * Compaction visualizer data model. Walks the events of a single session + * in chronological order and produces one CompactionPoint per assistant + * turn (or per compaction marker), with the context fill % at that + * moment. + */ + +/** Default context window we assume when a model-specific number isn't + * known. 200k covers Claude 3.5/3.6 Sonnet + Opus. Users can override + * via `AGENTWATCH_CONTEXT_WINDOW`. */ +const DEFAULT_CONTEXT_WINDOW = 200_000; + +export interface CompactionPoint { + kind: "turn" | "compaction"; + ts: string; + /** Context fill in [0,1]. For compaction points, the BEFORE value. */ + fillBefore: number; + /** For compaction points, the fill after the reset (usually ~0). */ + fillAfter?: number; + /** Tokens making up the before value (assistant turns only). */ + tokensBefore?: number; + tokensAfter?: number; + /** Human label for the x-axis. */ + label: string; +} + +export interface CompactionSeries { + sessionId: string; + contextWindow: number; + points: CompactionPoint[]; + compactionCount: number; + maxFill: number; +} + +export function contextWindow(): number { + const env = process.env.AGENTWATCH_CONTEXT_WINDOW; + if (env) { + const n = Number(env); + if (Number.isFinite(n) && n > 0) return n; + } + return DEFAULT_CONTEXT_WINDOW; +} + +/** Build the context-fill time series for a session. */ +export function buildCompactionSeries( + events: AgentEvent[], + sessionId: string, + window: number = contextWindow(), +): CompactionSeries { + const inSession = events + .filter((e) => e.sessionId === sessionId) + .sort((a, b) => (a.ts < b.ts ? -1 : 1)); + + const points: CompactionPoint[] = []; + let compactionCount = 0; + let maxFill = 0; + let turnIdx = 0; + let lastFill = 0; + let lastTokens = 0; + + for (const e of inSession) { + if (e.type === "compaction") { + compactionCount += 1; + points.push({ + kind: "compaction", + ts: e.ts, + fillBefore: lastFill, + fillAfter: 0, + tokensBefore: lastTokens, + tokensAfter: 0, + label: "⋈", + }); + lastFill = 0; + lastTokens = 0; + continue; + } + const u = e.details?.usage; + if (!u) continue; + turnIdx += 1; + const tokens = u.input + u.cacheRead + u.cacheCreate; + const fill = Math.min(1, tokens / window); + if (fill > maxFill) maxFill = fill; + lastFill = fill; + lastTokens = tokens; + points.push({ + kind: "turn", + ts: e.ts, + fillBefore: fill, + tokensBefore: tokens, + label: `t${turnIdx}`, + }); + } + + return { + sessionId, + contextWindow: window, + points, + compactionCount, + maxFill, + }; +} + +/** Render the series to a single ASCII line ≤ maxWidth chars. Turns use + * Unicode block characters whose height encodes fill %. Compactions + * are rendered as `⋈`. */ +export function renderCompactionBar( + series: CompactionSeries, + maxWidth: number, +): string { + if (series.points.length === 0) return ""; + const blocks = " ▁▂▃▄▅▆▇█"; + const points = series.points.slice(-maxWidth); // tail-fit if too long + let out = ""; + for (const p of points) { + if (p.kind === "compaction") { + out += "⋈"; + continue; + } + const idx = Math.min( + blocks.length - 1, + Math.max(0, Math.round(p.fillBefore * (blocks.length - 1))), + ); + out += blocks[idx]!; + } + return out; +} diff --git a/src/util/cost.test.ts b/src/util/cost.test.ts new file mode 100644 index 0000000..132286b --- /dev/null +++ b/src/util/cost.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + _resetPricingCache, + costOf, + formatUSD, + loadRates, + parseUsage, +} from "./cost.js"; + +const ENV = "AGENTWATCH_PRICING_PATH"; +const DEBUG_ENV = "AGENTWATCH_PRICING_DEBUG"; + +function withPricingFile(json: object): string { + const dir = mkdtempSync(join(tmpdir(), "aw-pricing-")); + const path = join(dir, "pricing.json"); + writeFileSync(path, JSON.stringify(json)); + return path; +} + +describe("costOf + loadRates", () => { + const original = process.env[ENV]; + + beforeEach(() => { + _resetPricingCache(); + }); + afterEach(() => { + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + delete process.env[DEBUG_ENV]; + _resetPricingCache(); + }); + + it("falls back to baked-in defaults when no pricing file exists", () => { + process.env[ENV] = "/nonexistent/agentwatch-pricing.json"; + const cost = costOf("claude-sonnet-4-6", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + expect(cost).toBeCloseTo(3.0, 5); + }); + + it("AUR-216: an entry in the user pricing file overrides the default for that model", () => { + process.env[ENV] = withPricingFile({ + "claude-sonnet-4-6": { + input: 999.0, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }, + }); + const cost = costOf("claude-sonnet-4-6", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + expect(cost).toBeCloseTo(999.0, 5); + }); + + it("preserves defaults for models the user file does not mention", () => { + process.env[ENV] = withPricingFile({ + "my-experimental": { + input: 0, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }, + }); + // claude-sonnet-4-6 was not overridden — still $3/M input. + const cost = costOf("claude-sonnet-4-6", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + expect(cost).toBeCloseTo(3.0, 5); + // The new model is now priced (and cheap). + const c2 = costOf("my-experimental", { + input: 5_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + expect(c2).toBe(0); + }); + + it("drops invalid entries (missing field, negative, wrong type)", () => { + process.env[ENV] = withPricingFile({ + "claude-opus-4-6": { + input: -1, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }, + "claude-sonnet-4-6": { + input: 5, + cacheCreate: 0, + cacheRead: 0, + // missing output + }, + "claude-haiku-4-5": "not-an-object", + }); + const rates = loadRates(); + // Defaults survived because all three overrides were rejected. + expect(rates["claude-opus-4-6"]?.input).toBe(15.0); + expect(rates["claude-sonnet-4-6"]?.input).toBe(3.0); + expect(rates["claude-haiku-4-5"]?.input).toBe(1.0); + }); + + it("normalizes model variants (gpt-5.4 → gpt-5, gemini-2.5-pro-preview → gemini-2.5-pro)", () => { + const a = costOf("gpt-5.4-preview", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + const b = costOf("gpt-5", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + expect(a).toBeCloseTo(b, 5); + }); + + it("falls back to default rates for unknown models", () => { + const cost = costOf("totally-unknown-model", { + input: 1_000_000, + cacheCreate: 0, + cacheRead: 0, + output: 0, + }); + // default.input is 3.0 (sonnet-equivalent fallback). + expect(cost).toBeCloseTo(3.0, 5); + }); +}); + +describe("formatUSD", () => { + it("uses adaptive precision based on magnitude", () => { + expect(formatUSD(0)).toBe("$0"); + expect(formatUSD(0.001)).toBe("$0.0010"); + expect(formatUSD(0.5)).toBe("$0.500"); + expect(formatUSD(12.4)).toBe("$12.40"); + }); +}); + +describe("parseUsage", () => { + it("returns the four-field object when all keys are present", () => { + const u = parseUsage({ + input_tokens: 10, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 100, + output_tokens: 50, + }); + expect(u).toEqual({ + input: 10, + cacheCreate: 5, + cacheRead: 100, + output: 50, + }); + }); + + it("returns null when nothing useful is present", () => { + expect(parseUsage(null)).toBeNull(); + expect(parseUsage({})).toBeNull(); + expect( + parseUsage({ + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 0, + }), + ).toBeNull(); + }); +}); diff --git a/src/util/cost.ts b/src/util/cost.ts new file mode 100644 index 0000000..49e34e2 --- /dev/null +++ b/src/util/cost.ts @@ -0,0 +1,216 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Per-million-token rates in USD. AUR-216: defaults below ship with + * the CLI, but operators can override or add new models by writing a + * JSON file at `~/.agentwatch/pricing.json` (or wherever the env var + * AGENTWATCH_PRICING_PATH points). The file is shape: + * + * { + * "claude-opus-4-6": { "input": 15.0, "cacheCreate": 18.75, ... }, + * "gpt-5": { "input": 1.5, "output": 11.0, ... }, + * "my-local-model": { "input": 0, "output": 0 } + * } + * + * The model key is the normalized name (see normalizeModel below). + * The user file is shallow-merged into the defaults — any model + * present in the user file wins for that whole entry; other defaults + * are preserved. Partial overrides at the field level are NOT + * supported (it's all four numbers, or nothing) so we never silently + * use a stale field if the operator only wrote `input`. */ +const DEFAULT_RATES: Record< + string, + { + input: number; + cacheCreate: number; + cacheRead: number; + output: number; + } +> = { + "claude-opus-4-6": { + input: 15.0, + cacheCreate: 18.75, + cacheRead: 1.5, + output: 75.0, + }, + "claude-sonnet-4-6": { + input: 3.0, + cacheCreate: 3.75, + cacheRead: 0.3, + output: 15.0, + }, + "claude-haiku-4-5": { + input: 1.0, + cacheCreate: 1.25, + cacheRead: 0.1, + output: 5.0, + }, + // Gemini 2.5 Pro — Jan 2026 public rates. + "gemini-2.5-pro": { + input: 1.25, + cacheCreate: 1.25, + cacheRead: 0.31, + output: 10.0, + }, + "gemini-2.5-flash": { + input: 0.075, + cacheCreate: 0.075, + cacheRead: 0.019, + output: 0.3, + }, + // Codex (GPT-5.x-class) — public OpenAI pricing, Jan 2026. + "gpt-5": { + input: 1.25, + cacheCreate: 1.25, + cacheRead: 0.125, + output: 10.0, + }, + "gpt-5-mini": { + input: 0.25, + cacheCreate: 0.25, + cacheRead: 0.025, + output: 2.0, + }, + // Fallback for unknown / synthetic models + default: { + input: 3.0, + cacheCreate: 3.75, + cacheRead: 0.3, + output: 15.0, + }, +}; + +export type Rate = (typeof DEFAULT_RATES)[string]; + +let cachedRates: Record<string, Rate> | null = null; + +export function pricingFilePath(): string { + return ( + process.env.AGENTWATCH_PRICING_PATH ?? + join(homedir(), ".agentwatch", "pricing.json") + ); +} + +/** Validate a single rate entry — every field must be a non-negative + * number. Returns null for invalid shapes so the caller can keep the + * default for that model. */ +function coerceRate(v: unknown): Rate | null { + if (!v || typeof v !== "object") return null; + const r = v as Record<string, unknown>; + const isNonNegNumber = (x: unknown): x is number => + typeof x === "number" && Number.isFinite(x) && x >= 0; + if ( + !isNonNegNumber(r.input) || + !isNonNegNumber(r.cacheCreate) || + !isNonNegNumber(r.cacheRead) || + !isNonNegNumber(r.output) + ) { + return null; + } + return { + input: r.input, + cacheCreate: r.cacheCreate, + cacheRead: r.cacheRead, + output: r.output, + }; +} + +export function loadRates(): Record<string, Rate> { + if (cachedRates) return cachedRates; + const path = pricingFilePath(); + const merged: Record<string, Rate> = { ...DEFAULT_RATES }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + const doc = JSON.parse(raw); + if (doc && typeof doc === "object") { + for (const [model, value] of Object.entries( + doc as Record<string, unknown>, + )) { + const rate = coerceRate(value); + if (rate) merged[model] = rate; + else if (process.env.AGENTWATCH_PRICING_DEBUG) { + // eslint-disable-next-line no-console + console.error( + `[agentwatch/cost] dropping invalid pricing entry for "${model}" — needs input/cacheCreate/cacheRead/output non-negative numbers`, + ); + } + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.error( + `[agentwatch/cost] failed to read ${path}: ${String(err)}; using built-in defaults`, + ); + } + } + cachedRates = merged; + return merged; +} + +/** @internal Test-only: drop the cached rates so the next loadRates() + * call re-reads the file. Lets tests point AGENTWATCH_PRICING_PATH at + * a fixture and observe the override. */ +export function _resetPricingCache(): void { + cachedRates = null; +} + +export interface Usage { + input: number; + cacheCreate: number; + cacheRead: number; + output: number; +} + +/** Returns USD cost for a single message's usage object. */ +export function costOf(model: string, u: Usage): number { + const rates = loadRates(); + const rate = rates[normalizeModel(model)] ?? rates.default!; + return ( + (u.input * rate.input + + u.cacheCreate * rate.cacheCreate + + u.cacheRead * rate.cacheRead + + u.output * rate.output) / + 1_000_000 + ); +} + +export function formatUSD(n: number): string { + if (n === 0) return "$0"; + if (n < 0.01) return `$${n.toFixed(4)}`; + if (n < 1) return `$${n.toFixed(3)}`; + return `$${n.toFixed(2)}`; +} + +function normalizeModel(model: string): string { + // e.g. "claude-opus-4-6[1m]" → "claude-opus-4-6" + // "gpt-5.4" → "gpt-5", "gemini-2.5-pro-preview" → "gemini-2.5-pro" + const base = model.replace(/\[.*?\]$/, "").toLowerCase(); + if (base.startsWith("gpt-5")) { + if (base.includes("mini")) return "gpt-5-mini"; + return "gpt-5"; + } + if (base.startsWith("gemini-2.5")) { + if (base.includes("flash")) return "gemini-2.5-flash"; + return "gemini-2.5-pro"; + } + return base; +} + +export function parseUsage(obj: unknown): Usage | null { + if (!obj || typeof obj !== "object") return null; + const o = obj as Record<string, unknown>; + const input = typeof o.input_tokens === "number" ? o.input_tokens : 0; + const cacheCreate = + typeof o.cache_creation_input_tokens === "number" + ? o.cache_creation_input_tokens + : 0; + const cacheRead = + typeof o.cache_read_input_tokens === "number" + ? o.cache_read_input_tokens + : 0; + const output = typeof o.output_tokens === "number" ? o.output_tokens : 0; + if (input + cacheCreate + cacheRead + output === 0) return null; + return { input, cacheCreate, cacheRead, output }; +} diff --git a/src/util/cross-search.test.ts b/src/util/cross-search.test.ts new file mode 100644 index 0000000..bc6e22b --- /dev/null +++ b/src/util/cross-search.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { searchAllSessions } from "./cross-search.js"; + +describe("searchAllSessions", () => { + it("returns empty for empty query without touching disk", () => { + expect(searchAllSessions("")).toEqual([]); + }); + + it("finds hits in Claude-style session files under a fake home", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cross-search-")); + const claudeDir = path.join( + tmp, + ".claude", + "projects", + "-Users-me-IdeaProjects-myproj", + ); + fs.mkdirSync(claudeDir, { recursive: true }); + const session = path.join(claudeDir, "abc123.jsonl"); + fs.writeFileSync( + session, + [ + '{"type":"user","content":"hello needle"}', + '{"type":"assistant","content":"unrelated"}', + '{"type":"user","content":"another needle line"}', + ].join("\n"), + ); + + const hits = searchAllSessions("needle", 10, tmp); + expect(hits.length).toBeGreaterThanOrEqual(2); + expect(hits[0]!.agent).toBe("claude-code"); + expect(hits[0]!.sessionId).toBe("abc123"); + expect(hits[0]!.project).toBe("myproj"); + expect(hits[0]!.line).toContain("needle"); + + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("respects the limit argument", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cross-search-lim-")); + const dir = path.join(tmp, ".claude", "projects", "-a"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "s.jsonl"), + Array.from({ length: 20 }, () => "needle").join("\n"), + ); + const hits = searchAllSessions("needle", 5, tmp); + expect(hits).toHaveLength(5); + fs.rmSync(tmp, { recursive: true, force: true }); + }); +}); diff --git a/src/util/cross-search.ts b/src/util/cross-search.ts new file mode 100644 index 0000000..22db464 --- /dev/null +++ b/src/util/cross-search.ts @@ -0,0 +1,201 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import type { AgentName } from "../schema.js"; + +export interface SearchHit { + agent: AgentName; + sessionId: string; + project: string; + path: string; + lineNumber: number; + line: string; + /** ISO timestamp sniffed from the raw line (before truncation). */ + ts?: string; +} + +const MAX_LINE = 500; + +/** Cross-session text search spanning every local agent history file we + * know about (~/.claude/projects, ~/.codex/sessions). Prefers ripgrep + * for speed; falls back to a native scan when rg isn't installed. */ +export function searchAllSessions( + query: string, + limit: number = 50, + home: string = os.homedir(), +): SearchHit[] { + if (!query) return []; + const roots = sessionRoots(home); + if (roots.length === 0) return []; + const rg = hasRipgrep(); + let hits = rg ? searchWithRipgrep(query, roots, limit) : []; + // Defensive: if rg returns nothing, fall back to native — covers + // older rg builds where the glob pattern misbehaves and would + // otherwise leave the user staring at an empty result. + if (hits.length === 0) hits = searchNative(query, roots, limit); + return hits.slice(0, limit); +} + +function sessionRoots(home: string): string[] { + const out: string[] = []; + const claude = path.join(home, ".claude", "projects"); + if (fs.existsSync(claude)) out.push(claude); + const codex = path.join(home, ".codex", "sessions"); + if (fs.existsSync(codex)) out.push(codex); + const gemini = path.join(home, ".gemini", "tmp"); + if (fs.existsSync(gemini)) out.push(gemini); + return out; +} + +function hasRipgrep(): boolean { + try { + const r = spawnSync("rg", ["--version"], { stdio: "ignore" }); + return r.status === 0; + } catch { + return false; + } +} + +function searchWithRipgrep( + query: string, + roots: string[], + limit: number, +): SearchHit[] { + // Two separate --glob args instead of brace expansion. Brace + // expansion in ripgrep's globset is supported but flaky across rg + // versions; two flags are unambiguous. + const args = [ + "--fixed-strings", + "--ignore-case", + "--no-heading", + "--line-number", + "--glob", + "*.jsonl", + "--glob", + "*.json", + query, + ...roots, + ]; + const r = spawnSync("rg", args, { encoding: "utf8" }); + if (r.status !== 0 && r.status !== 1) return []; + const hits: SearchHit[] = []; + for (const line of (r.stdout ?? "").split("\n")) { + if (!line) continue; + const m = line.match(/^(.+?):(\d+):(.*)$/); + if (!m) continue; + const hit = hitFromPath(m[1]!, Number(m[2]), m[3]!); + if (hit) hits.push(hit); + if (hits.length >= limit) break; + } + return hits; +} + +function searchNative( + query: string, + roots: string[], + limit: number, +): SearchHit[] { + const needle = query.toLowerCase(); + const hits: SearchHit[] = []; + for (const root of roots) { + for (const file of walk(root)) { + if (hits.length >= limit) return hits; + if (!file.endsWith(".jsonl") && !file.endsWith(".json")) continue; + try { + const lines = fs.readFileSync(file, "utf8").split("\n"); + for (let i = 0; i < lines.length && hits.length < limit; i++) { + if (lines[i]!.toLowerCase().includes(needle)) { + const hit = hitFromPath(file, i + 1, lines[i]!); + if (hit) hits.push(hit); + } + } + } catch { + /* unreadable */ + } + } + } + return hits; +} + +function* walk(dir: string): IterableIterator<string> { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) yield* walk(full); + else if (e.isFile()) yield full; + } +} + +/** Sniff an ISO timestamp from a raw JSONL line. Regex before JSON.parse + * because some lines are multi-MB and we don't need to parse them fully. + * Returns null if no ts field is present. */ +function sniffTs(line: string): string | undefined { + const m = line.match(/"(?:timestamp|ts|time|createdAt)"\s*:\s*"([^"]{10,35})"/); + return m ? m[1] : undefined; +} + +function hitFromPath( + file: string, + lineNumber: number, + line: string, +): SearchHit | null { + // Sniff the timestamp BEFORE we truncate so the field survives even + // on very long lines. + const ts = sniffTs(line); + const trimmed = line.length > MAX_LINE ? line.slice(0, MAX_LINE) + "…" : line; + const isClaude = file.includes(path.sep + ".claude" + path.sep + "projects"); + const isCodex = file.includes(path.sep + ".codex" + path.sep + "sessions"); + const isGemini = file.includes(path.sep + ".gemini" + path.sep + "tmp"); + if (isClaude) { + const parts = file.split(path.sep); + const projIdx = parts.lastIndexOf("projects"); + const projectDir = parts[projIdx + 1] ?? ""; + const project = projectDir.split("-").filter(Boolean).slice(-1)[0] ?? projectDir; + const sessionId = path.basename(file, ".jsonl"); + return { + agent: "claude-code", + sessionId, + project, + path: file, + lineNumber, + line: trimmed, + ts, + }; + } + if (isCodex) { + const m = path.basename(file, ".jsonl").match(/rollout-[0-9T:\-.]+-(.+)$/); + return { + agent: "codex", + sessionId: m?.[1] ?? path.basename(file, ".jsonl"), + project: "", + path: file, + lineNumber, + line: trimmed, + ts, + }; + } + if (isGemini) { + // …/.gemini/tmp/<project>/chats/session-YYYY-MM-DDTHH-MM-<hash>.json + const parts = file.split(path.sep); + const tmpIdx = parts.lastIndexOf("tmp"); + const project = parts[tmpIdx + 1] ?? ""; + const base = path.basename(file, ".json"); + const m = base.match(/^session-[0-9T:\-]+-(.+)$/); + return { + agent: "gemini", + sessionId: m?.[1] ?? base, + project, + path: file, + lineNumber, + line: trimmed, + ts, + }; + } + return null; +} diff --git a/src/util/export.test.ts b/src/util/export.test.ts new file mode 100644 index 0000000..565c8fc --- /dev/null +++ b/src/util/export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { exportSession, sessionToMarkdown } from "./export.js"; +import type { AgentEvent } from "../schema.js"; + +const sample: AgentEvent[] = [ + { + id: "1", + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "prompt", + sessionId: "sess-abc12345", + riskScore: 0, + summary: "[demo] user prompt", + details: { fullText: "hello agent" }, + }, + { + id: "2", + ts: "2026-04-15T10:00:05Z", + agent: "claude-code", + type: "shell_exec", + sessionId: "sess-abc12345", + riskScore: 6, + cmd: "ls -la", + tool: "Bash", + details: { toolResult: "total 0" }, + }, +]; + +describe("sessionToMarkdown", () => { + it("renders events ordered by timestamp with prompt + command blocks", () => { + const md = sessionToMarkdown(sample, "sess-abc12345", "claude-code"); + expect(md).toContain("agentwatch session export"); + expect(md).toContain("**Agent:** claude-code"); + expect(md).toContain("> hello agent"); + expect(md).toContain("```sh\nls -la\n```"); + expect(md).toContain("```sh\ntotal 0\n```"); + }); +}); + +describe("exportSession", () => { + it("writes both .md and .json files with session-prefixed names", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentwatch-export-")); + const result = exportSession( + sample, + "sess-abc12345", + "claude-code", + dir, + new Date("2026-04-15T10:05:00Z"), + ); + expect(fs.existsSync(result.mdPath)).toBe(true); + expect(fs.existsSync(result.jsonPath)).toBe(true); + expect(path.basename(result.mdPath)).toMatch(/^claude-code-sess-abc-/); + const json = JSON.parse(fs.readFileSync(result.jsonPath, "utf8")); + expect(json).toHaveLength(2); + expect(json[0].id).toBe("1"); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/util/export.ts b/src/util/export.ts new file mode 100644 index 0000000..0bb571c --- /dev/null +++ b/src/util/export.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { AgentEvent, AgentName } from "../schema.js"; + +export const EXPORT_DIR = "agentwatch-export"; + +export interface ExportResult { + mdPath: string; + jsonPath: string; +} + +/** Write both .md and .json files for a session's events and return the paths. */ +export function exportSession( + events: AgentEvent[], + sessionId: string, + agent: AgentName, + cwd: string = process.cwd(), + now: Date = new Date(), +): ExportResult { + const outDir = path.join(cwd, EXPORT_DIR); + fs.mkdirSync(outDir, { recursive: true }); + const stamp = now.toISOString().replace(/[:.]/g, "-"); + const slug = sessionId.slice(0, 8) || "session"; + const base = `${agent}-${slug}-${stamp}`; + const mdPath = path.join(outDir, `${base}.md`); + const jsonPath = path.join(outDir, `${base}.json`); + fs.writeFileSync(mdPath, sessionToMarkdown(events, sessionId, agent)); + fs.writeFileSync(jsonPath, JSON.stringify(events, null, 2)); + return { mdPath, jsonPath }; +} + +/** Render a session's events as a human-readable markdown transcript. */ +export function sessionToMarkdown( + events: AgentEvent[], + sessionId: string, + agent: AgentName, +): string { + const ordered = [...events].sort((a, b) => (a.ts < b.ts ? -1 : 1)); + const lines: string[] = []; + lines.push(`# agentwatch session export`); + lines.push(""); + lines.push(`- **Agent:** ${agent}`); + lines.push(`- **Session:** \`${sessionId}\``); + lines.push(`- **Events:** ${ordered.length}`); + if (ordered.length > 0) { + lines.push(`- **From:** ${ordered[0]!.ts}`); + lines.push(`- **To:** ${ordered[ordered.length - 1]!.ts}`); + } + lines.push(""); + lines.push("---"); + lines.push(""); + for (const e of ordered) { + lines.push(...renderEvent(e)); + lines.push(""); + } + return lines.join("\n"); +} + +function renderEvent(e: AgentEvent): string[] { + const out: string[] = []; + const header = `## ${e.ts} · ${e.type}${e.tool ? ` · ${e.tool}` : ""}`; + out.push(header); + if (e.summary) out.push(`*${e.summary}*`); + const d = e.details ?? {}; + if (e.type === "prompt" && d.fullText) { + out.push("", "**User:**", "", quote(d.fullText)); + } else if (e.type === "response" && d.fullText) { + out.push("", "**Assistant:**", "", d.fullText.trim()); + } else if (e.cmd) { + out.push("", "```sh", e.cmd, "```"); + } else if (e.path && (e.type === "file_read" || e.type === "file_write" || e.type === "file_change")) { + out.push("", `\`${e.path}\``); + } + if (d.thinking) out.push("", "**Thinking:**", "", quote(d.thinking)); + if (d.toolInput) out.push("", "**Input:**", "", fenced(JSON.stringify(d.toolInput, null, 2), "json")); + if (d.toolResult) { + const lang = inferLang(e); + out.push("", d.toolError ? "**Result (error):**" : "**Result:**", "", fenced(d.toolResult, lang)); + } + if (d.usage) { + const { input, cacheCreate, cacheRead, output } = d.usage; + const cost = d.cost ? ` · $${d.cost.toFixed(4)}` : ""; + out.push("", `_tokens: in=${input} cacheCreate=${cacheCreate} cacheRead=${cacheRead} out=${output}${cost}_`); + } + return out; +} + +function quote(s: string): string { + return s + .trim() + .split("\n") + .map((l) => `> ${l}`) + .join("\n"); +} + +function fenced(s: string, lang: string): string { + return "```" + lang + "\n" + s.trimEnd() + "\n```"; +} + +function inferLang(e: AgentEvent): string { + if (e.cmd || e.type === "shell_exec") return "sh"; + const p = e.path ?? ""; + const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase(); + const map: Record<string, string> = { + ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", + py: "py", rs: "rust", go: "go", java: "java", + md: "md", json: "json", yml: "yaml", yaml: "yaml", + sh: "sh", bash: "sh", sql: "sql", toml: "toml", + }; + return map[ext] ?? ""; +} diff --git a/src/util/feature-contract.test.ts b/src/util/feature-contract.test.ts new file mode 100644 index 0000000..e1edcd0 --- /dev/null +++ b/src/util/feature-contract.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { + parseFeatureContract, + readAllFeatureContracts, +} from "./feature-contract.js"; + +/** + * Feature-gate enforcement. Every doc under docs/features/ is a contract: + * GOAL, USER_VALUE, COUNTERFACTUAL. Missing any one → CI fails. + * + * The council verdict: if USER_VALUE is generic ("better UX") or + * COUNTERFACTUAL is vacuous ("nothing"), the feature is bloat and + * should be killed. The format cannot enforce quality — only presence — + * so treat review as the second gate. + */ + +describe("parseFeatureContract", () => { + it("extracts all three fields", () => { + const md = `# Search + +## Contract + +**GOAL:** In-buffer full-text filter over the live timeline. +**USER_VALUE:** Find one event fast in a stream of hundreds. +**COUNTERFACTUAL:** Without it, users scroll manually through 500 rows. + +## Rest of doc +`; + const c = parseFeatureContract("search", md); + expect(c).toEqual({ + slug: "search", + goal: "In-buffer full-text filter over the live timeline.", + userValue: "Find one event fast in a stream of hundreds.", + counterfactual: + "Without it, users scroll manually through 500 rows.", + }); + }); + + it("reports every missing field", () => { + const md = `# Broken + +Just prose, no contract. +`; + const c = parseFeatureContract("broken", md); + expect(c).toMatchObject({ + slug: "broken", + missing: expect.arrayContaining(["GOAL", "USER_VALUE", "COUNTERFACTUAL"]), + }); + }); + + it("treats a whitespace-only value as missing", () => { + const md = `**GOAL:** +**USER_VALUE:** real value. +**COUNTERFACTUAL:** real counterfactual. +`; + const c = parseFeatureContract("partial", md); + expect(c).toMatchObject({ slug: "partial", missing: ["GOAL"] }); + }); +}); + +describe("every docs/features/*.md has a contract", () => { + // This is the actual gate — if a feature doc ships without a contract + // (or if someone deletes a field), CI fails here. + const dir = join(process.cwd(), "docs", "features"); + const contracts = readAllFeatureContracts(dir); + + it("finds at least one feature doc", () => { + expect(contracts.length).toBeGreaterThan(0); + }); + + for (const c of contracts) { + it(`${c.slug}: contract is complete`, () => { + if ("missing" in c) { + throw new Error( + `docs/features/${c.slug}.md missing: ${c.missing.join(", ")}. ` + + `Add the contract header (see CONTRIBUTING.md § Feature gate).`, + ); + } + expect(c.goal.length).toBeGreaterThan(10); + expect(c.userValue.length).toBeGreaterThan(10); + expect(c.counterfactual.length).toBeGreaterThan(10); + }); + } +}); diff --git a/src/util/feature-contract.ts b/src/util/feature-contract.ts new file mode 100644 index 0000000..835b95b --- /dev/null +++ b/src/util/feature-contract.ts @@ -0,0 +1,64 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export interface FeatureContract { + /** Basename without extension, e.g. "search". */ + slug: string; + /** One-line intent: what does this feature accomplish. */ + goal: string; + /** Why a user cares. If this is generic ("better UX"), the feature + * is bloat and should be killed. */ + userValue: string; + /** What breaks if this feature is removed. Defines the testable + * regression surface. */ + counterfactual: string; +} + +const FIELDS = [ + { key: "goal", label: "GOAL" }, + { key: "userValue", label: "USER_VALUE" }, + { key: "counterfactual", label: "COUNTERFACTUAL" }, +] as const; + +export function parseFeatureContract( + slug: string, + markdown: string, +): FeatureContract | { slug: string; missing: string[] } { + const lines = markdown.split(/\r?\n/); + const fields: Record<string, string> = {}; + const missing: string[] = []; + for (const { key, label } of FIELDS) { + const prefix = `**${label}:**`; + const hit = lines.find((l) => l.trimStart().startsWith(prefix)); + if (!hit) { + missing.push(label); + continue; + } + const value = hit.trimStart().slice(prefix.length).trim(); + if (value.length === 0) { + missing.push(label); + continue; + } + fields[key] = value; + } + if (missing.length > 0) return { slug, missing }; + return { + slug, + goal: fields.goal!, + userValue: fields.userValue!, + counterfactual: fields.counterfactual!, + }; +} + +export function readAllFeatureContracts( + dir: string, +): Array<FeatureContract | { slug: string; missing: string[] }> { + const files = readdirSync(dir).filter((f) => f.endsWith(".md")); + return files + .filter((f) => f !== "README.md") + .map((f) => { + const slug = f.replace(/\.md$/, ""); + const body = readFileSync(join(dir, f), "utf8"); + return parseFeatureContract(slug, body); + }); +} diff --git a/src/util/gemini-permissions.ts b/src/util/gemini-permissions.ts new file mode 100644 index 0000000..ff14c01 --- /dev/null +++ b/src/util/gemini-permissions.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Gemini CLI permission surface from ~/.gemini/settings.json and + * ~/.gemini/trustedFolders.json. + */ + +export interface GeminiPermissions { + settingsPath: string; + trustedFoldersPath: string; + authType?: string; + selectedModel?: string; + trustedFolders: string[]; + toolsAllow?: string[]; + toolsBlock?: string[]; + present: boolean; +} + +export function readGeminiPermissions( + home: string = os.homedir(), +): GeminiPermissions { + const settingsPath = path.join(home, ".gemini", "settings.json"); + const trustedFoldersPath = path.join(home, ".gemini", "trustedFolders.json"); + const out: GeminiPermissions = { + settingsPath, + trustedFoldersPath, + trustedFolders: [], + present: false, + }; + if (!fs.existsSync(settingsPath)) return out; + out.present = true; + try { + const raw = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + const sec = (raw?.security ?? {}) as Record<string, unknown>; + const auth = (sec.auth ?? {}) as Record<string, unknown>; + out.authType = + typeof auth.selectedType === "string" + ? auth.selectedType + : typeof auth.method === "string" + ? auth.method + : undefined; + out.selectedModel = + typeof raw.selectedModel === "string" + ? raw.selectedModel + : typeof raw.model === "string" + ? raw.model + : undefined; + const tools = (raw.tools ?? {}) as Record<string, unknown>; + if (Array.isArray(tools.allow)) { + out.toolsAllow = tools.allow as string[]; + } + if (Array.isArray(tools.block)) { + out.toolsBlock = tools.block as string[]; + } + } catch { + /* unreadable settings */ + } + try { + const raw = JSON.parse(fs.readFileSync(trustedFoldersPath, "utf8")); + if (Array.isArray(raw)) { + out.trustedFolders = raw.filter((x): x is string => typeof x === "string"); + } else if (raw && typeof raw === "object") { + out.trustedFolders = Object.keys(raw); + } + } catch { + /* no trusted folders file */ + } + return out; +} diff --git a/src/util/highlight.test.ts b/src/util/highlight.test.ts new file mode 100644 index 0000000..e703dea --- /dev/null +++ b/src/util/highlight.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { highlight, inferLang } from "./highlight.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: "x", + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "tool_call", + riskScore: 0, + ...o, +}); + +describe("inferLang", () => { + it("picks bash for Bash tool or shell_exec", () => { + expect(inferLang(evt({ tool: "Bash" }))).toBe("bash"); + expect(inferLang(evt({ type: "shell_exec" }))).toBe("bash"); + expect(inferLang(evt({ cmd: "ls" }))).toBe("bash"); + }); + + it("maps file extensions to highlight.js language ids", () => { + expect(inferLang(evt({ path: "src/app.ts" }))).toBe("typescript"); + expect(inferLang(evt({ path: "x.py" }))).toBe("python"); + expect(inferLang(evt({ path: "config.yaml" }))).toBe("yaml"); + }); + + it("auto-detects JSON from content when extension is unknown", () => { + expect(inferLang(evt({}), '{"a":1}')).toBe("json"); + expect(inferLang(evt({}), "plain text")).toBeNull(); + }); +}); + +describe("highlight", () => { + it("returns the original string when language is null or unsupported", () => { + expect(highlight("hello", null)).toBe("hello"); + expect(highlight("hello", "not-a-real-lang-zzz")).toBe("hello"); + }); + + it("does not throw and returns a non-empty string for a supported language", () => { + // cli-highlight strips colors when stdout is not a TTY (vitest), so we + // only assert the call succeeds and returns the source text intact. + const out = highlight("const x = 1;", "typescript"); + expect(typeof out).toBe("string"); + expect(out).toContain("const"); + }); +}); diff --git a/src/util/highlight.ts b/src/util/highlight.ts new file mode 100644 index 0000000..8a25842 --- /dev/null +++ b/src/util/highlight.ts @@ -0,0 +1,46 @@ +import { highlight as cliHighlight, supportsLanguage } from "cli-highlight"; +import type { AgentEvent } from "../schema.js"; + +/** Map tool/extension to cli-highlight language id. `null` = no highlighting. */ +export function inferLang(event: AgentEvent, content?: string): string | null { + if (event.tool === "Bash" || event.type === "shell_exec" || event.cmd) { + return "bash"; + } + const p = event.path ?? ""; + const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase(); + const byExt: Record<string, string> = { + ts: "typescript", tsx: "typescript", + js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", + py: "python", + rs: "rust", go: "go", + java: "java", kt: "kotlin", swift: "swift", + c: "c", h: "c", cpp: "cpp", hpp: "cpp", cc: "cpp", + cs: "csharp", rb: "ruby", php: "php", + md: "markdown", markdown: "markdown", + json: "json", yml: "yaml", yaml: "yaml", toml: "toml", + sh: "bash", bash: "bash", zsh: "bash", + sql: "sql", html: "html", css: "css", scss: "scss", + }; + if (byExt[ext]) return byExt[ext]!; + if (content?.trim().startsWith("{") || content?.trim().startsWith("[")) { + try { + JSON.parse(content); + return "json"; + } catch { + /* not JSON */ + } + } + return null; +} + +/** Highlight `content` as the given language and return an ANSI-colored + * string. Falls back to the original content if the language is unknown + * or highlighting fails. */ +export function highlight(content: string, lang: string | null): string { + if (!lang || !supportsLanguage(lang)) return content; + try { + return cliHighlight(content, { language: lang, ignoreIllegals: true }); + } catch { + return content; + } +} diff --git a/src/util/jsonl-stream.test.ts b/src/util/jsonl-stream.test.ts new file mode 100644 index 0000000..bdae654 --- /dev/null +++ b/src/util/jsonl-stream.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + mkdtempSync, + writeFileSync, + appendFileSync, + statSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { readNewlineTerminatedLines } from "./jsonl-stream.js"; + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "jsonl-stream-")); +} + +describe("readNewlineTerminatedLines", () => { + it("returns terminated lines and a newline-aligned consumed count", () => { + const dir = tmp(); + const file = join(dir, "x.jsonl"); + writeFileSync(file, '{"a":1}\n{"b":2}\n'); + const { lines, consumed } = readNewlineTerminatedLines(file, 0, 15); + expect(lines).toEqual(['{"a":1}', '{"b":2}']); + expect(consumed).toBe(16); + }); + + it("drops the trailing partial line and reports consumed up to last \\n", () => { + const dir = tmp(); + const file = join(dir, "x.jsonl"); + writeFileSync(file, '{"a":1}\n{"b":2'); // no trailing newline + const { lines, consumed } = readNewlineTerminatedLines(file, 0, 13); + expect(lines).toEqual(['{"a":1}']); + // Consumed only counts up to and including the last \n. + expect(consumed).toBe(8); + }); + + it("recovers a previously-partial line after the rest is appended (AUR-227)", () => { + const dir = tmp(); + const file = join(dir, "x.jsonl"); + // Producer flushes a partial line. + writeFileSync(file, '{"id":"a","value":'); + let stat = statSync(file); + const first = readNewlineTerminatedLines(file, 0, stat.size - 1); + // No terminated lines yet; nothing consumed. + expect(first.lines).toEqual([]); + expect(first.consumed).toBe(0); + + // Producer flushes the rest of that line plus the next one. + appendFileSync(file, '1}\n{"id":"b","value":2}\n'); + stat = statSync(file); + const second = readNewlineTerminatedLines( + file, + first.consumed, + stat.size - 1, + ); + // The originally-partial line is now recovered intact. + expect(second.lines).toEqual([ + '{"id":"a","value":1}', + '{"id":"b","value":2}', + ]); + expect(first.consumed + second.consumed).toBe(stat.size); + }); + + it("handles an empty slice", () => { + const dir = tmp(); + const file = join(dir, "x.jsonl"); + writeFileSync(file, ""); + const { lines, consumed } = readNewlineTerminatedLines(file, 0, -1); + expect(lines).toEqual([]); + expect(consumed).toBe(0); + }); + + it("preserves utf-8 multibyte characters across the newline boundary", () => { + const dir = tmp(); + const file = join(dir, "x.jsonl"); + writeFileSync(file, '{"x":"héllo"}\n{"y":"日本"}\n'); + const stat = statSync(file); + const { lines, consumed } = readNewlineTerminatedLines( + file, + 0, + stat.size - 1, + ); + expect(lines).toEqual(['{"x":"héllo"}', '{"y":"日本"}']); + expect(consumed).toBe(stat.size); + }); +}); diff --git a/src/util/jsonl-stream.ts b/src/util/jsonl-stream.ts new file mode 100644 index 0000000..fc840a9 --- /dev/null +++ b/src/util/jsonl-stream.ts @@ -0,0 +1,46 @@ +import { closeSync, openSync, readSync } from "node:fs"; + +export interface LineBatch { + /** Newline-terminated lines from the slice (newline stripped). The + * trailing partial line, if any, is dropped — caller will re-read it + * on the next iteration once more bytes arrive. */ + lines: string[]; + /** Bytes consumed from `start`. Always points at a newline boundary or + * zero. The caller advances their cursor by exactly this amount; any + * unterminated tail stays unread until the next call. */ + consumed: number; +} + +/** Read [start, end] from `file` synchronously and return only the + * newline-terminated lines plus the byte count of those terminated + * lines. Used by JSONL adapters whose source files can be flushed + * mid-line by their producing process — under the previous readline + * implementation we'd parse the partial line, fail JSON.parse, advance + * past it, and permanently lose the event when the rest of the line was + * later appended. AUR-227. */ +export function readNewlineTerminatedLines( + file: string, + start: number, + end: number, +): LineBatch { + if (end < start) return { lines: [], consumed: 0 }; + const size = end - start + 1; + const buf = Buffer.alloc(size); + let read = 0; + const fd = openSync(file, "r"); + try { + while (read < size) { + const n = readSync(fd, buf, read, size - read, start + read); + if (n <= 0) break; + read += n; + } + } finally { + closeSync(fd); + } + const slice = read < size ? buf.subarray(0, read) : buf; + const lastNl = slice.lastIndexOf(0x0a); + if (lastNl < 0) return { lines: [], consumed: 0 }; + const terminated = slice.subarray(0, lastNl).toString("utf8"); + const lines = terminated === "" ? [] : terminated.split("\n"); + return { lines, consumed: lastNl + 1 }; +} diff --git a/src/util/memory-file.test.ts b/src/util/memory-file.test.ts new file mode 100644 index 0000000..c5fa22b --- /dev/null +++ b/src/util/memory-file.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { memoryFilesFor } from "./memory-file.js"; + +function withFakeHome(fn: (home: string, cwd: string) => void): void { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "mf-home-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "mf-cwd-")); + try { + fn(home, cwd); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(cwd, { recursive: true, force: true }); + } +} + +describe("memoryFilesFor", () => { + it("reads CLAUDE.md for claude-code", () => { + withFakeHome((home, cwd) => { + fs.writeFileSync(path.join(cwd, "CLAUDE.md"), "# project claude"); + const info = memoryFilesFor("claude-code", cwd, home); + expect(info.paths).toHaveLength(1); + expect(info.text).toContain("project claude"); + }); + }); + + it("concatenates workspace + home Claude memory", () => { + withFakeHome((home, cwd) => { + fs.writeFileSync(path.join(cwd, "CLAUDE.md"), "A"); + fs.mkdirSync(path.join(home, ".claude"), { recursive: true }); + fs.writeFileSync(path.join(home, ".claude", "CLAUDE.md"), "B"); + const info = memoryFilesFor("claude-code", cwd, home); + expect(info.paths).toHaveLength(2); + expect(info.text).toContain("A"); + expect(info.text).toContain("B"); + }); + }); + + it("reads AGENTS.md for codex", () => { + withFakeHome((home, cwd) => { + fs.writeFileSync(path.join(cwd, "AGENTS.md"), "codex memory"); + expect(memoryFilesFor("codex", cwd, home).text).toContain("codex memory"); + }); + }); + + it("reads .cursorrules + .cursor/rules/*.mdc for cursor", () => { + withFakeHome((home, cwd) => { + fs.writeFileSync(path.join(cwd, ".cursorrules"), "ROOT RULES"); + fs.mkdirSync(path.join(cwd, ".cursor", "rules"), { recursive: true }); + fs.writeFileSync(path.join(cwd, ".cursor", "rules", "a.mdc"), "RULE A"); + const info = memoryFilesFor("cursor", cwd, home); + expect(info.paths.length).toBeGreaterThanOrEqual(2); + expect(info.text).toContain("ROOT RULES"); + expect(info.text).toContain("RULE A"); + }); + }); + + it("returns empty info when no memory file exists", () => { + withFakeHome((home, cwd) => { + expect(memoryFilesFor("claude-code", cwd, home)).toEqual({ + paths: [], + text: "", + }); + }); + }); + + it("returns empty info for agents without a memory-file convention", () => { + withFakeHome((home, cwd) => { + expect(memoryFilesFor("cline", cwd, home).text).toBe(""); + }); + }); +}); diff --git a/src/util/memory-file.ts b/src/util/memory-file.ts new file mode 100644 index 0000000..dabeeac --- /dev/null +++ b/src/util/memory-file.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AgentName } from "../schema.js"; + +/** + * Per-agent memory-file resolution. Each agent has its own convention + * for project-level system instructions; we resolve the right file for + * a given session's (agent, cwd) pair and tokenize it so the token + * attribution view can report it as a real overhead category. + * + * Files we look for, in priority order per agent: + * claude-code : <cwd>/CLAUDE.md + ~/.claude/CLAUDE.md (concatenated) + * codex : <cwd>/AGENTS.md + ~/.codex/AGENTS.md + * gemini : <cwd>/GEMINI.md + ~/.gemini/GEMINI.md + * cursor : <cwd>/.cursorrules (+ .cursor/rules/*.mdc) + * windsurf : <cwd>/.windsurfrules + * aider : <cwd>/CONVENTIONS.md + * openclaw : <cwd>/OPENCLAW.md (convention; user-defined) + * (anything else) : no memory file + */ + +export interface MemoryFileInfo { + paths: string[]; + text: string; +} + +export function memoryFilesFor( + agent: AgentName, + cwd: string, + home: string = os.homedir(), +): MemoryFileInfo { + const paths: string[] = []; + switch (agent) { + case "claude-code": + paths.push(path.join(cwd, "CLAUDE.md")); + paths.push(path.join(home, ".claude", "CLAUDE.md")); + break; + case "codex": + paths.push(path.join(cwd, "AGENTS.md")); + paths.push(path.join(home, ".codex", "AGENTS.md")); + break; + case "gemini": + paths.push(path.join(cwd, "GEMINI.md")); + paths.push(path.join(home, ".gemini", "GEMINI.md")); + break; + case "cursor": + paths.push(path.join(cwd, ".cursorrules")); + // .cursor/rules/*.mdc — read all if the dir exists + try { + const rulesDir = path.join(cwd, ".cursor", "rules"); + for (const name of fs.readdirSync(rulesDir)) { + if (name.endsWith(".mdc") || name.endsWith(".md")) { + paths.push(path.join(rulesDir, name)); + } + } + } catch { + /* no cursor rules dir */ + } + break; + case "windsurf": + paths.push(path.join(cwd, ".windsurfrules")); + break; + case "aider": + paths.push(path.join(cwd, "CONVENTIONS.md")); + paths.push(path.join(cwd, ".aider.conf.yml")); + break; + case "openclaw": + paths.push(path.join(cwd, "OPENCLAW.md")); + break; + default: + /* unknown / cline / continue — no convention yet */ + break; + } + const existing: string[] = []; + const chunks: string[] = []; + for (const p of paths) { + try { + const text = fs.readFileSync(p, "utf8"); + existing.push(p); + chunks.push(text); + } catch { + /* missing — fine */ + } + } + return { paths: existing, text: chunks.join("\n\n---\n\n") }; +} diff --git a/src/util/notifier.ts b/src/util/notifier.ts new file mode 100644 index 0000000..3ac8642 --- /dev/null +++ b/src/util/notifier.ts @@ -0,0 +1,132 @@ +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; +import type { AgentEvent } from "../schema.js"; +import { evalTriggers } from "./triggers.js"; + +/** + * Desktop notifications for agentwatch. Fires on a small set of default + * rules: .env access, dangerous shell commands, tool errors, high token + * budget. Custom user-defined regex triggers land in AUR-108 (M6). + */ + +/** Rate-limit: one notification per rule, per target, per 60s. */ +const RATE_MS = 60_000; +const recent = new Map<string, number>(); +let notifierDisabled = false; + +export function shouldNotify(event: AgentEvent): null | { + title: string; + body: string; +} { + // `.env` access (read or write) + if ( + (event.type === "file_read" || event.type === "file_write") && + event.path && + /(^|\/)\.env($|\.)/.test(event.path) + ) { + return gate(`env:${event.path}`, { + title: "⚠ agentwatch — .env access", + body: `${event.agent} ${event.type} ${event.path}`, + }); + } + + // SSH / AWS / GnuPG credential paths + if ( + event.path && + /(^|\/)(\.ssh|\.aws|\.gnupg)($|\/)/.test(event.path) + ) { + return gate(`creds:${event.path}`, { + title: "⚠ agentwatch — credential path touched", + body: `${event.agent} ${event.type} ${event.path}`, + }); + } + + // Dangerous shell commands + if (event.type === "shell_exec" && event.cmd) { + const cmd = event.cmd; + if (/\brm\s+-rf\b/.test(cmd)) { + return gate(`rm-rf:${cmd.slice(0, 40)}`, { + title: "⚠ agentwatch — rm -rf", + body: `${event.agent}: ${cmd.slice(0, 160)}`, + }); + } + if (/\bsudo\b/.test(cmd)) { + return gate(`sudo:${cmd.slice(0, 40)}`, { + title: "⚠ agentwatch — sudo", + body: `${event.agent}: ${cmd.slice(0, 160)}`, + }); + } + if (/curl[^|]*\|\s*(sh|bash)/.test(cmd)) { + return gate(`curl-sh:${cmd.slice(0, 40)}`, { + title: "⚠ agentwatch — curl | sh", + body: `${event.agent}: ${cmd.slice(0, 160)}`, + }); + } + } + + // Tool errors + if (event.details?.toolError) { + const tool = event.tool ?? "tool"; + return gate(`err:${tool}:${event.sessionId ?? ""}`, { + title: `⚠ agentwatch — ${tool} failed`, + body: `${event.agent} in ${projectOf(event) ?? "?"}: ${event.summary ?? ""}`.slice(0, 200), + }); + } + + // User-defined triggers (~/.agentwatch/triggers.json) + const custom = evalTriggers(event); + if (custom) { + return gate(`user:${custom.title}:${event.sessionId ?? ""}`, custom); + } + + return null; +} + +function gate(key: string, payload: { title: string; body: string }) { + const now = Date.now(); + const last = recent.get(key); + if (last && now - last < RATE_MS) return null; + recent.set(key, now); + return payload; +} + +function projectOf(event: AgentEvent): string | undefined { + const m = (event.summary ?? "").match(/^\[([^\]/ ]+)/); + return m?.[1]; +} + +/** Fire a desktop notification. Silent no-op if the platform tool is + * missing or something is wired up wrong — never crashes the TUI. */ +export function notify(title: string, body: string): void { + if (notifierDisabled) return; + const os = platform(); + // Ink raw-mode TTY breaks inherited stdio on child processes; always + // use explicit ignore stdio so the notifier never clobbers our TUI. + const silentStdio = { + stdio: ["ignore", "ignore", "ignore"] as Array<"ignore">, + }; + try { + if (os === "darwin") { + const escTitle = title.replace(/"/g, '\\"'); + const escBody = body.replace(/"/g, '\\"'); + spawnSync( + "osascript", + ["-e", `display notification "${escBody}" with title "${escTitle}"`], + silentStdio, + ); + return; + } + if (os === "linux") { + spawnSync("notify-send", [title, body], silentStdio); + return; + } + if (os === "win32") { + const msg = `[System.Windows.Forms.MessageBox]::Show('${body.replace(/'/g, "''")}', '${title.replace(/'/g, "''")}')`; + spawnSync("powershell", ["-Command", msg], silentStdio); + return; + } + } catch { + // Stifle — disable notifier for the session so we don't spam errors. + notifierDisabled = true; + } +} diff --git a/src/util/open-url.ts b/src/util/open-url.ts new file mode 100644 index 0000000..15ee14f --- /dev/null +++ b/src/util/open-url.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; +import { platform } from "node:os"; + +/** Open a URL in the user's default browser. Non-blocking, best-effort; + * silently no-ops if `open` / `xdg-open` / `start` aren't available. */ +export function openUrl(url: string): void { + try { + const p = platform(); + if (p === "darwin") { + spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + } else if (p === "win32") { + spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); + } else { + spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + } + } catch { + // intentional no-op + } +} diff --git a/src/util/openclaw-config.ts b/src/util/openclaw-config.ts new file mode 100644 index 0000000..1eb6569 --- /dev/null +++ b/src/util/openclaw-config.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface OpenClawAgent { + id: string; + default?: boolean; + workspace?: string; + model?: string; + name?: string; + emoji?: string; +} + +export interface OpenClawConfig { + source: string; + defaultWorkspace?: string; + agents: OpenClawAgent[]; +} + +export function readOpenClawConfig(): OpenClawConfig | null { + const path = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as Record<string, unknown>; + const agentsObj = (obj.agents ?? {}) as Record<string, unknown>; + const defaults = (agentsObj.defaults ?? {}) as Record<string, unknown>; + const list = Array.isArray(agentsObj.list) ? agentsObj.list : []; + return { + source: path, + defaultWorkspace: + typeof defaults.workspace === "string" ? defaults.workspace : undefined, + agents: list + .filter((a: unknown): a is Record<string, unknown> => + typeof a === "object" && a !== null, + ) + .map((a) => { + const identity = (a.identity ?? {}) as Record<string, unknown>; + return { + id: typeof a.id === "string" ? a.id : "unknown", + default: a.default === true, + workspace: + typeof a.workspace === "string" ? a.workspace : undefined, + model: typeof a.model === "string" ? a.model : undefined, + name: typeof identity.name === "string" ? identity.name : undefined, + emoji: + typeof identity.emoji === "string" ? identity.emoji : undefined, + }; + }), + }; + } catch { + return null; + } +} diff --git a/src/util/openclaw-cron.test.ts b/src/util/openclaw-cron.test.ts new file mode 100644 index 0000000..dc69841 --- /dev/null +++ b/src/util/openclaw-cron.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { classifySessionKey, readCronJobs } from "./openclaw-cron.js"; + +describe("readCronJobs", () => { + it("returns [] when the jobs file is missing", () => { + expect(readCronJobs("/tmp/this-does-not-exist.json")).toEqual([]); + }); + + it("parses a real `every` job (matches openclaw cron add --json output)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ocron-")); + const file = path.join(tmp, "jobs.json"); + fs.writeFileSync( + file, + JSON.stringify({ + version: 1, + jobs: [ + { + id: "abc-123", + agentId: "content", + name: "demo", + enabled: true, + schedule: { kind: "every", everyMs: 300_000, anchorMs: 1 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "test" }, + delivery: { mode: "announce", channel: "last" }, + state: { nextRunAtMs: 1776341493440 }, + }, + ], + }), + ); + const jobs = readCronJobs(file); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + id: "abc-123", + agentId: "content", + name: "demo", + enabled: true, + schedule: "every 5m", + scheduleKind: "every", + intervalMs: 300_000, + nextRunAtMs: 1776341493440, + message: "test", + deliveryChannel: "last", + }); + fs.rmSync(tmp, { recursive: true }); + }); + + it("ignores entries missing required fields", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ocron-")); + const file = path.join(tmp, "jobs.json"); + fs.writeFileSync( + file, + JSON.stringify({ jobs: [{ name: "no id" }, { id: "x", name: "ok" }] }), + ); + expect(readCronJobs(file)).toHaveLength(1); + fs.rmSync(tmp, { recursive: true }); + }); +}); + +describe("classifySessionKey", () => { + it("returns null for ordinary interactive session keys", () => { + expect(classifySessionKey("agent:content:main", undefined)).toBeNull(); + expect( + classifySessionKey("agent:content:main", { origin: { provider: "user" } }), + ).toBeNull(); + }); + + it("flags heartbeat sessions via origin.provider", () => { + const m = classifySessionKey("agent:content:main", { + origin: { provider: "heartbeat" }, + }); + expect(m).toEqual({ kind: "heartbeat", agentId: "content" }); + }); + + it("flags cron-spawned sessions via the :cron: key fragment", () => { + const m = classifySessionKey( + "agent:content:cron:abc-123-def", + undefined, + ); + expect(m).toEqual({ + kind: "cron", + agentId: "content", + jobId: "abc-123-def", + runId: undefined, + }); + }); + + it("captures runId for per-run cron session keys", () => { + const m = classifySessionKey( + "agent:content:cron:abc-123:run:run-xyz", + undefined, + ); + expect(m).toEqual({ + kind: "cron", + agentId: "content", + jobId: "abc-123", + runId: "run-xyz", + }); + }); +}); diff --git a/src/util/openclaw-cron.ts b/src/util/openclaw-cron.ts new file mode 100644 index 0000000..0b43b9c --- /dev/null +++ b/src/util/openclaw-cron.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Parser for OpenClaw's cron job store at `~/.openclaw/cron/jobs.json`. + * + * Real shape verified by running `openclaw cron add --json`: + * { + * id, agentId, name, enabled, createdAtMs, updatedAtMs, + * schedule: { kind: "every"|"cron"|"at", everyMs?, expr?, atMs?, anchorMs }, + * sessionTarget: "isolated"|"main", + * wakeMode: "now"|"next-heartbeat", + * payload: { kind: "agentTurn"|"systemEvent", message? }, + * delivery: { mode, channel }, + * state: { nextRunAtMs } + * } + */ + +export interface CronJob { + id: string; + agentId: string; + name: string; + enabled: boolean; + schedule: string; + scheduleKind: "every" | "cron" | "at" | "unknown"; + intervalMs?: number; + nextRunAtMs?: number; + sessionTarget?: string; + wakeMode?: string; + message?: string; + deliveryChannel?: string; +} + +export const CRON_JOBS_PATH = path.join(os.homedir(), ".openclaw", "cron", "jobs.json"); + +/** Read + parse the cron jobs store. Returns [] when the file doesn't + * exist (no cron jobs defined yet) or fails to parse. */ +export function readCronJobs(file: string = CRON_JOBS_PATH): CronJob[] { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return []; + } + let doc: unknown; + try { + doc = JSON.parse(raw); + } catch { + return []; + } + const jobs = (doc as { jobs?: unknown }).jobs; + if (!Array.isArray(jobs)) return []; + return jobs + .map(parseJob) + .filter((j): j is CronJob => j !== null); +} + +function parseJob(j: unknown): CronJob | null { + if (!j || typeof j !== "object") return null; + const r = j as Record<string, unknown>; + if (typeof r.id !== "string" || typeof r.name !== "string") return null; + const schedule = (r.schedule ?? {}) as Record<string, unknown>; + const kind = scheduleKind(schedule); + const payload = (r.payload ?? {}) as Record<string, unknown>; + const delivery = (r.delivery ?? {}) as Record<string, unknown>; + const state = (r.state ?? {}) as Record<string, unknown>; + return { + id: r.id, + agentId: typeof r.agentId === "string" ? r.agentId : "main", + name: r.name, + enabled: r.enabled !== false, + schedule: scheduleString(schedule, kind), + scheduleKind: kind, + intervalMs: typeof schedule.everyMs === "number" ? schedule.everyMs : undefined, + nextRunAtMs: typeof state.nextRunAtMs === "number" ? state.nextRunAtMs : undefined, + sessionTarget: + typeof r.sessionTarget === "string" ? r.sessionTarget : undefined, + wakeMode: typeof r.wakeMode === "string" ? r.wakeMode : undefined, + message: typeof payload.message === "string" ? payload.message : undefined, + deliveryChannel: + typeof delivery.channel === "string" ? delivery.channel : undefined, + }; +} + +function scheduleKind(s: Record<string, unknown>): CronJob["scheduleKind"] { + if (s.kind === "every" || s.kind === "cron" || s.kind === "at") return s.kind; + return "unknown"; +} + +function scheduleString( + s: Record<string, unknown>, + kind: CronJob["scheduleKind"], +): string { + if (kind === "every" && typeof s.everyMs === "number") { + return `every ${humanizeMs(s.everyMs)}`; + } + if (kind === "cron" && typeof s.expr === "string") return s.expr; + if (kind === "at" && typeof s.atMs === "number") { + return `at ${new Date(s.atMs).toISOString()}`; + } + return "?"; +} + +export function humanizeMs(ms: number): string { + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`; + return `${Math.round(ms / 86_400_000)}d`; +} + +/** + * Inspect an OpenClaw sessions.json entry and decide whether it + * represents a scheduled run. + * + * Live data shows two markers: + * - sessionKey containing `:cron:` → cron-spawned session + * - origin.provider === "heartbeat" → heartbeat-triggered session + * + * Returns the metadata to attach to events from this session, or null + * if it's an interactive/manual session. + */ +export interface ScheduledMarker { + kind: "cron" | "heartbeat"; + jobId?: string; + agentId?: string; + runId?: string; +} + +export function classifySessionKey( + sessionKey: string, + entry: Record<string, unknown> | undefined, +): ScheduledMarker | null { + // Heartbeat: origin.provider === "heartbeat" + const origin = (entry?.origin ?? {}) as Record<string, unknown>; + if (origin.provider === "heartbeat") { + return { + kind: "heartbeat", + agentId: agentIdFromKey(sessionKey), + }; + } + // Cron: sessionKey shape `agent:<agentId>:cron:<jobId>` or + // `agent:<agentId>:cron:<jobId>:run:<runId>`. Job/run ids are usually + // UUIDs but we accept any non-colon token to be defensive against + // future runtime changes. + const m = sessionKey.match( + /^agent:([^:]+):cron:([^:]+)(?::run:([^:]+))?$/, + ); + if (m) { + return { + kind: "cron", + agentId: m[1], + jobId: m[2], + runId: m[3] ?? undefined, + }; + } + return null; +} + +function agentIdFromKey(sessionKey: string): string | undefined { + const m = sessionKey.match(/^agent:([^:]+):/); + return m?.[1]; +} diff --git a/src/util/openclaw-heartbeat.test.ts b/src/util/openclaw-heartbeat.test.ts new file mode 100644 index 0000000..0b4e0ab --- /dev/null +++ b/src/util/openclaw-heartbeat.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + discoverHeartbeatFiles, + readHeartbeatFile, +} from "./openclaw-heartbeat.js"; + +function writeHeartbeat(content: string): string { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ohb-")); + const dir = path.join(tmp, "workspace-test"); + fs.mkdirSync(dir); + const file = path.join(dir, "HEARTBEAT.md"); + fs.writeFileSync(file, content); + return file; +} + +describe("readHeartbeatFile", () => { + it("reports empty for the literal template file (comments only)", () => { + const file = writeHeartbeat( + "# HEARTBEAT.md Template\n\n```\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n```\n", + ); + const status = readHeartbeatFile(file)!; + expect(status.empty).toBe(true); + expect(status.tasks).toHaveLength(0); + }); + + it("parses a `## tasks` block of bullet items", () => { + const file = writeHeartbeat( + "# Workspace heartbeat\n\n## tasks\n- Check email inbox\n- Summarise yesterday's commits\n- Tidy ~/Downloads\n", + ); + const status = readHeartbeatFile(file)!; + expect(status.empty).toBe(false); + expect(status.tasks.map((t) => t.text)).toEqual([ + "Check email inbox", + "Summarise yesterday's commits", + "Tidy ~/Downloads", + ]); + }); + + it("falls back to the first non-empty paragraph when no tasks block exists", () => { + const file = writeHeartbeat( + "# Some heartbeat\n\nKeep an eye on the build pipeline\n", + ); + const status = readHeartbeatFile(file)!; + expect(status.tasks).toHaveLength(1); + expect(status.tasks[0]!.text).toBe("Keep an eye on the build pipeline"); + }); + + it("returns null when the file doesn't exist", () => { + expect(readHeartbeatFile("/tmp/no-such-heartbeat.md")).toBeNull(); + }); +}); + +describe("discoverHeartbeatFiles", () => { + it("finds HEARTBEAT.md inside every ~/.openclaw/workspace-* dir", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ohbhome-")); + fs.mkdirSync(path.join(home, ".openclaw", "workspace-main"), { + recursive: true, + }); + fs.writeFileSync( + path.join(home, ".openclaw", "workspace-main", "HEARTBEAT.md"), + "# main\n", + ); + fs.mkdirSync(path.join(home, ".openclaw", "workspace-research"), { + recursive: true, + }); + fs.writeFileSync( + path.join(home, ".openclaw", "workspace-research", "HEARTBEAT.md"), + "# research\n", + ); + // sibling that should be ignored + fs.mkdirSync(path.join(home, ".openclaw", "agents"), { recursive: true }); + + const files = discoverHeartbeatFiles(home); + expect(files).toHaveLength(2); + expect(files.every((f) => f.endsWith("/HEARTBEAT.md"))).toBe(true); + fs.rmSync(home, { recursive: true }); + }); +}); diff --git a/src/util/openclaw-heartbeat.ts b/src/util/openclaw-heartbeat.ts new file mode 100644 index 0000000..ef956d3 --- /dev/null +++ b/src/util/openclaw-heartbeat.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * OpenClaw HEARTBEAT.md parser. + * + * Heartbeat in OpenClaw is a periodic main-session turn (interval is + * configured per agent in gateway config — see `agents.defaults.heartbeat`). + * The HEARTBEAT.md file in each workspace holds the user-curated + * checklist the agent reads on each fire. Empty / template HEARTBEAT.md + * → heartbeat skipped with `reason=empty-heartbeat-file`. + * + * The file convention varies. We accept either: + * + * # Anything header + * + * ## tasks + * - Check inbox for urgent emails + * - Summarise yesterday's commits + * + * Or freeform paragraphs (we capture the first non-empty line as a + * single task). Comments-only files (lines starting with `#` only) and + * empty files are reported as having zero tasks. + */ + +export interface HeartbeatTask { + text: string; + /** Source workspace label (e.g. `workspace-main`). */ + workspace: string; + /** Absolute path to the HEARTBEAT.md the task came from. */ + source: string; +} + +export interface HeartbeatStatus { + workspace: string; + source: string; + tasks: HeartbeatTask[]; + /** True when the file exists but contains only comments or blank + * lines (matches the `reason=empty-heartbeat-file` skip). */ + empty: boolean; +} + +const COMMENT_LINE = /^\s*(?:>|<!--|```|#)/; +const TASKS_HEADER = /^\s*##\s*tasks\s*$/i; + +export function readHeartbeatFile(file: string): HeartbeatStatus | null { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return null; + } + const workspace = path.basename(path.dirname(file)); + const lines = raw.split("\n"); + const tasks: HeartbeatTask[] = []; + let inTasksBlock = false; + let sawAnyContent = false; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (line.trim() === "") continue; + if (TASKS_HEADER.test(line)) { + inTasksBlock = true; + continue; + } + // Header that isn't `## tasks` ends the tasks block. + if (line.startsWith("##") || line.startsWith("# ")) { + if (inTasksBlock) inTasksBlock = false; + continue; + } + if (COMMENT_LINE.test(line)) { + continue; + } + sawAnyContent = true; + if (inTasksBlock && /^\s*[-*+]\s+/.test(line)) { + tasks.push({ + text: line.replace(/^\s*[-*+]\s+/, "").trim(), + workspace, + source: file, + }); + } else if (!inTasksBlock && tasks.length === 0) { + // No tasks block — treat the first non-empty paragraph as one task + // so the user gets *something* on the dashboard. + tasks.push({ text: line.trim(), workspace, source: file }); + } + } + + return { + workspace, + source: file, + tasks, + empty: !sawAnyContent || tasks.length === 0, + }; +} + +/** Discover every HEARTBEAT.md inside ~/.openclaw/workspace-* dirs. */ +export function discoverHeartbeatFiles(home: string = os.homedir()): string[] { + const root = path.join(home, ".openclaw"); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + const out: string[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + if (!e.name.startsWith("workspace")) continue; + const candidate = path.join(root, e.name, "HEARTBEAT.md"); + try { + fs.statSync(candidate); + out.push(candidate); + } catch { + /* missing — skip */ + } + } + return out; +} + +/** Convenience: scan all heartbeat files at once. */ +export function readAllHeartbeats(home: string = os.homedir()): HeartbeatStatus[] { + return discoverHeartbeatFiles(home) + .map((f) => readHeartbeatFile(f)) + .filter((s): s is HeartbeatStatus => s !== null); +} diff --git a/src/util/otel.test.ts b/src/util/otel.test.ts new file mode 100644 index 0000000..defbff5 --- /dev/null +++ b/src/util/otel.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { systemOf, operationOf, otelEnabled } from "./otel.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: "x", + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "tool_call", + riskScore: 0, + ...o, +}); + +describe("systemOf", () => { + it("maps known agents to gen_ai.system values", () => { + expect(systemOf("claude-code")).toBe("anthropic"); + expect(systemOf("codex")).toBe("openai"); + expect(systemOf("aider")).toBe("openai"); + expect(systemOf("gemini")).toBe("google"); + expect(systemOf("cursor")).toBe("cursor"); + }); + + it("passes unknown agents through", () => { + expect(systemOf("newagent")).toBe("newagent"); + }); +}); + +describe("operationOf", () => { + it("classifies prompts/responses as chat", () => { + expect(operationOf(evt({ type: "prompt" }))).toBe("chat"); + expect(operationOf(evt({ type: "response" }))).toBe("chat"); + }); + + it("classifies tool-use-ish events as tool_use", () => { + expect(operationOf(evt({ type: "shell_exec" }))).toBe("tool_use"); + expect(operationOf(evt({ type: "file_write" }))).toBe("tool_use"); + expect(operationOf(evt({ type: "file_read" }))).toBe("tool_use"); + }); + + it("classifies compaction as context_compaction", () => { + expect(operationOf(evt({ type: "compaction" }))).toBe("context_compaction"); + }); +}); + +describe("otelEnabled", () => { + it("respects AGENTWATCH_OTLP_ENDPOINT", () => { + delete process.env.AGENTWATCH_OTLP_ENDPOINT; + expect(otelEnabled()).toBe(false); + process.env.AGENTWATCH_OTLP_ENDPOINT = "http://localhost:4318/v1/traces"; + expect(otelEnabled()).toBe(true); + delete process.env.AGENTWATCH_OTLP_ENDPOINT; + }); +}); diff --git a/src/util/otel.ts b/src/util/otel.ts new file mode 100644 index 0000000..985f887 --- /dev/null +++ b/src/util/otel.ts @@ -0,0 +1,274 @@ +import type { AgentEvent } from "../schema.js"; +import { contextWindow } from "./compaction.js"; +import { VERSION } from "./version.js"; + +/** + * Optional OTel exporter. Enabled when AGENTWATCH_OTLP_ENDPOINT is set + * (e.g. http://localhost:4318/v1/traces). Emits one span per AgentEvent + * with OpenTelemetry GenAI semantic conventions + * (https://opentelemetry.io/docs/specs/semconv/gen-ai/), so any OTel + * consumer (Jaeger, Tempo, Honeycomb, Grafana) can interpret the data + * without custom dashboards. + * + * Conventions emitted: + * gen_ai.system anthropic | openai | google | … + * gen_ai.operation.name chat | tool_use | file_op | … + * gen_ai.request.model claude-3-5-sonnet-… + * gen_ai.usage.input_tokens + * gen_ai.usage.output_tokens + * gen_ai.tool.name (tool_use operations) + * gen_ai.tool.call.id + * error.type on tool errors + * + * Plus a small agentwatch extension namespace for things GenAI semconv + * doesn't cover yet: + * agentwatch.session.id + * agentwatch.cost_usd + * agentwatch.cache_read_tokens + * agentwatch.cache_create_tokens + * agentwatch.cache_hit_ratio + * agentwatch.context.fill_pct + * agentwatch.risk_score + * agentwatch.callee (set on parent agent_call spans) + * + * Trace structure (AUR-202): + * When a Claude Bash event invokes another agent (`details.agentCall`), + * the resulting span id is captured. When the spawned child session's + * first event lands (`details.parentSpawnId === <bash event id>`), we + * look up the parent OTel span and use the JS API's context.with() + * to make subsequent child events nested under it. The result in + * Jaeger / Tempo: a single trace with Claude as root and Codex / + * Gemini as child spans, just like distributed-tracing for + * microservices. + */ + +let initialized = false; + +interface SpanHandle { + end: (endMs?: number) => void; +} + +interface SpanContext { + /** Opaque OTel span object — kept here so we can re-use it as a + * parent for downstream agent events. Type-erased to avoid leaking + * OTel types into the no-op codepath. */ + span: unknown; + /** Parent context object (also OTel-internal). */ + ctx: unknown; +} + +interface OtelImpl { + startSpan: ( + name: string, + attrs: Record<string, string | number | boolean>, + parent?: SpanContext, + ) => { handle: SpanHandle; context: SpanContext }; + attachToActive: (ctx: SpanContext, fn: () => void) => void; +} + +let impl: OtelImpl | null = null; + +/** AUR-202: parent-event-id → captured SpanContext, so child events + * whose `details.parentSpawnId` references this id can become real + * OTel children. Bounded to prevent leaks on long sessions. */ +const parentSpansById = new Map<string, SpanContext>(); +/** Session id → SpanContext to inherit. Set when we attach a span via + * parentSpawnId so subsequent events from the same child session + * also inherit the parent context (not just the very first one). */ +const sessionParentSpan = new Map<string, SpanContext>(); +const MAX_PARENT_SPANS = 1000; + +function rememberParent(eventId: string, ctx: SpanContext): void { + parentSpansById.set(eventId, ctx); + if (parentSpansById.size > MAX_PARENT_SPANS) { + const oldest = parentSpansById.keys().next().value; + if (oldest !== undefined) parentSpansById.delete(oldest); + } +} + +export function otelEnabled(): boolean { + return Boolean(process.env.AGENTWATCH_OTLP_ENDPOINT); +} + +export async function initOtel(): Promise<void> { + if (initialized) return; + initialized = true; + const endpoint = process.env.AGENTWATCH_OTLP_ENDPOINT; + if (!endpoint) return; + try { + const [{ NodeSDK }, { OTLPTraceExporter }, { resourceFromAttributes }, otelApi] = + await Promise.all([ + import("@opentelemetry/sdk-node"), + import("@opentelemetry/exporter-trace-otlp-http"), + import("@opentelemetry/resources"), + import("@opentelemetry/api"), + ]); + const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + "service.name": "agentwatch", + "service.version": VERSION, + }), + traceExporter: new OTLPTraceExporter({ url: endpoint }), + }); + sdk.start(); + const apiTracer = otelApi.trace.getTracer("agentwatch"); + impl = { + startSpan: (name, attrs, parent) => { + // If we have a parent context, start the span inside it so the + // OTel SDK records the parent_span_id automatically. + const parentCtx = parent + ? otelApi.trace.setSpan(otelApi.context.active(), parent.span as never) + : otelApi.context.active(); + const span = apiTracer.startSpan( + name, + { attributes: attrs }, + parentCtx, + ); + const ctx = otelApi.trace.setSpan(parentCtx, span); + return { + handle: { + end: (endMs?: number) => { + if (endMs != null) span.end(new Date(endMs)); + else span.end(); + }, + }, + context: { span, ctx }, + }; + }, + attachToActive: (_ctx, fn) => { + // Reserved for a future API. We attach via parent argument for + // now, but this hook lets callers run a synchronous block in + // the parent's active context if we ever need to (e.g. wrap + // multiple child spans in a single attach scope). + fn(); + }, + }; + const shutdown = () => void sdk.shutdown(); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + process.once("beforeExit", shutdown); + } catch (err) { + process.stderr.write(`[agentwatch/otel] init failed: ${String(err)}\n`); + impl = null; + } +} + +/** Map an agentwatch agent name to a gen_ai.system value. */ +export function systemOf(agent: string): string { + switch (agent) { + case "claude-code": + return "anthropic"; + case "codex": + case "aider": + return "openai"; + case "gemini": + return "google"; + case "cursor": + return "cursor"; + default: + return agent; + } +} + +/** Map an AgentEvent type to a gen_ai.operation.name value. */ +export function operationOf(event: AgentEvent): string { + switch (event.type) { + case "prompt": + case "response": + return "chat"; + case "tool_call": + case "shell_exec": + case "file_read": + case "file_write": + case "file_change": + return "tool_use"; + case "compaction": + return "context_compaction"; + case "session_start": + case "session_end": + return event.type; + default: + return event.type; + } +} + +/** Emit a span for a single AgentEvent. No-op when OTel isn't initialized. */ +export function emitEventSpan(event: AgentEvent): void { + if (!impl) return; + const attrs: Record<string, string | number | boolean> = { + "gen_ai.system": systemOf(event.agent), + "gen_ai.operation.name": operationOf(event), + "agentwatch.risk_score": event.riskScore, + }; + if (event.sessionId) attrs["agentwatch.session.id"] = event.sessionId; + if (event.tool) attrs["gen_ai.tool.name"] = event.tool; + if (event.path) attrs["agentwatch.path"] = event.path; + if (event.cmd) attrs["agentwatch.cmd"] = event.cmd.slice(0, 500); + + const d = event.details; + if (d?.model) { + attrs["gen_ai.request.model"] = d.model; + attrs["gen_ai.response.model"] = d.model; + } + if (d?.toolUseId) attrs["gen_ai.tool.call.id"] = d.toolUseId; + if (d?.cost != null) attrs["agentwatch.cost_usd"] = d.cost; + if (d?.durationMs != null) attrs["agentwatch.duration_ms"] = d.durationMs; + if (d?.toolError) { + attrs["error.type"] = "tool_error"; + } + if (d?.usage) { + const u = d.usage; + attrs["gen_ai.usage.input_tokens"] = u.input; + attrs["gen_ai.usage.output_tokens"] = u.output; + attrs["agentwatch.cache_read_tokens"] = u.cacheRead; + attrs["agentwatch.cache_create_tokens"] = u.cacheCreate; + const totalIn = u.input + u.cacheRead + u.cacheCreate; + if (totalIn > 0) { + attrs["agentwatch.cache_hit_ratio"] = u.cacheRead / totalIn; + attrs["agentwatch.context.fill_pct"] = Math.min( + 1, + totalIn / contextWindow(), + ); + } + } + if (d?.agentCall) { + attrs["agentwatch.callee"] = d.agentCall.callee; + if (d.agentCall.kind) attrs["agentwatch.call.kind"] = d.agentCall.kind; + } + + // AUR-202: figure out which (if any) parent OTel span this event + // should nest under. + let parent: SpanContext | undefined; + if (d?.parentSpawnId) { + const explicit = parentSpansById.get(d.parentSpawnId); + if (explicit) { + parent = explicit; + if (event.sessionId) sessionParentSpan.set(event.sessionId, explicit); + } + } + if (!parent && event.sessionId) { + const inherited = sessionParentSpan.get(event.sessionId); + if (inherited) parent = inherited; + } + + const startMs = new Date(event.ts).getTime(); + const spanName = d?.model + ? `${operationOf(event)} ${d.model}` + : `${operationOf(event)} ${event.agent}`; + const { handle, context } = impl.startSpan(spanName, attrs, parent); + const endMs = + d?.durationMs != null ? startMs + d.durationMs : startMs + 1; + handle.end(endMs); + + // If this event is itself an agent_call (the parent side), remember + // its span so a later child can attach under it. + if (d?.agentCall) { + rememberParent(event.id, context); + } +} + +/** Test helper — wipes parent-span memory between runs. */ +export function _resetOtelLinkage(): void { + parentSpansById.clear(); + sessionParentSpan.clear(); +} diff --git a/src/util/parse-errors.test.ts b/src/util/parse-errors.test.ts new file mode 100644 index 0000000..6ac5d09 --- /dev/null +++ b/src/util/parse-errors.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEvent, EventDetails, EventSink } from "../schema.js"; +import { createParseErrorTracker } from "./parse-errors.js"; + +function makeRecorder(): { + sink: EventSink; + emitted: AgentEvent[]; + enrichments: Array<{ id: string; patch: Partial<EventDetails> }>; +} { + const emitted: AgentEvent[] = []; + const enrichments: Array<{ id: string; patch: Partial<EventDetails> }> = []; + return { + sink: { + emit: (e) => emitted.push(e), + enrich: (id, patch) => enrichments.push({ id, patch }), + }, + emitted, + enrichments, + }; +} + +describe("createParseErrorTracker", () => { + it("emits one synthetic parse_error event on the first failure per session", () => { + const r = makeRecorder(); + const tracker = createParseErrorTracker("claude-code", r.sink); + tracker.recordFailure("sess-A", "{garbled"); + expect(r.emitted).toHaveLength(1); + expect(r.emitted[0]?.type).toBe("parse_error"); + expect(r.emitted[0]?.sessionId).toBe("sess-A"); + expect(r.emitted[0]?.details?.parseErrorCount).toBe(1); + expect(r.emitted[0]?.details?.parseErrorSample).toBe("{garbled"); + }); + + it("enriches the existing event on subsequent failures, not emit new ones", () => { + const r = makeRecorder(); + const tracker = createParseErrorTracker("codex", r.sink); + tracker.recordFailure("sess-B", "first bad"); + tracker.recordFailure("sess-B", "second bad"); + tracker.recordFailure("sess-B", "third bad"); + expect(r.emitted).toHaveLength(1); + expect(r.enrichments).toHaveLength(2); + expect(r.enrichments[1]?.patch.parseErrorCount).toBe(3); + expect(r.enrichments[1]?.patch.parseErrorSample).toBe("third bad"); + }); + + it("tracks separate counts per session", () => { + const r = makeRecorder(); + const tracker = createParseErrorTracker("openclaw", r.sink); + tracker.recordFailure("sess-X", "x1"); + tracker.recordFailure("sess-Y", "y1"); + tracker.recordFailure("sess-X", "x2"); + expect(r.emitted).toHaveLength(2); + const sessions = r.emitted.map((e) => e.sessionId).sort(); + expect(sessions).toEqual(["sess-X", "sess-Y"]); + // Y still at 1, X now at 2 (one enrichment). + expect(r.enrichments).toHaveLength(1); + expect(r.enrichments[0]?.patch.parseErrorCount).toBe(2); + }); + + it("truncates very long samples to keep the timeline readable", () => { + const r = makeRecorder(); + const tracker = createParseErrorTracker("claude-code", r.sink); + const long = "x".repeat(1000); + tracker.recordFailure("sess-Z", long); + const sample = r.emitted[0]?.details?.parseErrorSample ?? ""; + expect(sample.length).toBeLessThanOrEqual(200); + expect(sample.endsWith("…")).toBe(true); + }); +}); diff --git a/src/util/parse-errors.ts b/src/util/parse-errors.ts new file mode 100644 index 0000000..c412f7a --- /dev/null +++ b/src/util/parse-errors.ts @@ -0,0 +1,77 @@ +import type { AgentEvent, AgentName, EventSink } from "../schema.js"; +import { riskOf } from "../schema.js"; +import { nextId } from "./ids.js"; + +/** Per-session running tally of unparseable JSONL lines. AUR-228. The + * tracker emits a single synthetic `parse_error` event the first time + * a session fails to parse a line, and `enrich`es it on every + * subsequent failure with the new count + a truncated sample of the + * offending line. The TUI surfaces this as a session-level warning so + * operators know they're seeing a partial timeline. + * + * This sits behind the line-reading layer (jsonl-stream) so the count + * reflects only well-formed lines (newline-terminated) that JSON.parse + * rejected — i.e., genuine schema corruption, not the partial-flush + * case that AUR-227 already handles cleanly. */ +export interface ParseErrorTracker { + recordFailure(sessionKey: string, line: string): void; +} + +interface Entry { + count: number; + eventId?: string; +} + +const SAMPLE_BYTES = 200; + +export function createParseErrorTracker( + agent: AgentName, + sink: EventSink, + options: { + /** Override summary prefix; defaults to `[<sessionId-prefix>]`. */ + summaryPrefix?: (sessionKey: string) => string; + } = {}, +): ParseErrorTracker { + const entries = new Map<string, Entry>(); + return { + recordFailure(sessionKey: string, line: string): void { + let entry = entries.get(sessionKey); + if (!entry) { + entry = { count: 0 }; + entries.set(sessionKey, entry); + } + entry.count += 1; + const sample = truncate(line, SAMPLE_BYTES); + + if (!entry.eventId) { + const prefix = + options.summaryPrefix?.(sessionKey) ?? `[${sessionKey.slice(0, 8)}] `; + const event: AgentEvent = { + id: nextId(), + ts: new Date().toISOString(), + agent, + type: "parse_error", + sessionId: sessionKey, + riskScore: riskOf("parse_error"), + summary: `${prefix}⚠ unparseable line — context loss possible`, + details: { + parseErrorCount: 1, + parseErrorSample: sample, + }, + }; + entry.eventId = event.id; + sink.emit(event); + } else { + sink.enrich(entry.eventId, { + parseErrorCount: entry.count, + parseErrorSample: sample, + }); + } + }, + }; +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max - 1) + "…"; +} diff --git a/src/util/project-index.test.ts b/src/util/project-index.test.ts new file mode 100644 index 0000000..3fc72ad --- /dev/null +++ b/src/util/project-index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isStale, STALE_MS } from "./project-index.js"; + +describe("isStale", () => { + const now = Date.parse("2026-04-15T12:00:00Z"); + + it("returns false for an event inside the stale window", () => { + const fresh = new Date(now - STALE_MS + 1000).toISOString(); + expect(isStale(fresh, now)).toBe(false); + }); + + it("returns true for an event past the stale window", () => { + const old = new Date(now - STALE_MS - 1).toISOString(); + expect(isStale(old, now)).toBe(true); + }); + + it("returns false for a just-now event", () => { + expect(isStale(new Date(now).toISOString(), now)).toBe(false); + }); +}); diff --git a/src/util/project-index.ts b/src/util/project-index.ts new file mode 100644 index 0000000..fbe0737 --- /dev/null +++ b/src/util/project-index.ts @@ -0,0 +1,147 @@ +import type { AgentEvent, AgentName } from "../schema.js"; + +export interface ProjectRow { + /** Short label extracted from event prefix (`[auraqu]` → `auraqu`). */ + name: string; + /** Total events across every agent in this project. */ + events: number; + /** Per-agent event count. */ + byAgent: Map<AgentName, number>; + /** Unique session ids touching this project. */ + sessions: Set<string>; + /** Accumulated cost across all assistant turns in this project. */ + cost: number; + /** Most recent event timestamp (ISO). */ + lastTs: string; +} + +/** Derive the project index from the full event buffer. Cheap enough to + * recompute on every render for <5k events. Memoize via useMemo if hot. */ +export function buildProjectIndex(events: AgentEvent[]): ProjectRow[] { + const byName = new Map<string, ProjectRow>(); + for (const e of events) { + const name = extractProjectName(e); + if (!name) continue; + let row = byName.get(name); + if (!row) { + row = { + name, + events: 0, + byAgent: new Map(), + sessions: new Set(), + cost: 0, + lastTs: e.ts, + }; + byName.set(name, row); + } + row.events += 1; + row.byAgent.set(e.agent, (row.byAgent.get(e.agent) ?? 0) + 1); + if (e.sessionId) row.sessions.add(e.sessionId); + if (e.details?.cost) row.cost += e.details.cost; + if (e.ts > row.lastTs) row.lastTs = e.ts; + } + const rows = Array.from(byName.values()); + rows.sort((a, b) => (a.lastTs < b.lastTs ? 1 : -1)); + return rows; +} + +function extractProjectName(e: AgentEvent): string | null { + const s = e.summary ?? ""; + const m = s.match(/^\[([^\]/ ]+)/); + if (m) return m[1] ?? null; + // fall through for openclaw config.write events without a prefix + return null; +} + +export const STALE_MS = 5 * 60_000; + +export function isStale(iso: string, nowMs: number = Date.now()): boolean { + return nowMs - new Date(iso).getTime() > STALE_MS; +} + +export function agoFromNow(iso: string): string { + const then = new Date(iso).getTime(); + const diff = Date.now() - then; + if (diff < 60_000) return "just now"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; + return `${Math.floor(diff / 86400_000)}d ago`; +} + +export interface SessionRow { + sessionId: string; + agent: AgentName; + /** Sub-agent label for OpenClaw (content/research/etc.). */ + subAgent?: string; + project: string; + firstPrompt: string; + events: number; + firstTs: string; + lastTs: string; + cost: number; + hasError: boolean; +} + +/** Return one row per session in a given project, newest first. */ +export function buildSessionRows( + events: AgentEvent[], + project: string, +): SessionRow[] { + const byId = new Map<string, SessionRow>(); + for (const e of events) { + const p = (e.summary ?? "").match(/^\[([^\]/ ]+)/)?.[1]; + if (p !== project) continue; + const sid = e.sessionId; + if (!sid) continue; + let row = byId.get(sid); + if (!row) { + row = { + sessionId: sid, + agent: e.agent, + subAgent: extractSubAgent(e), + project, + firstPrompt: "", + events: 0, + firstTs: e.ts, + lastTs: e.ts, + cost: 0, + hasError: false, + }; + byId.set(sid, row); + } + row.events += 1; + if (e.ts < row.firstTs) row.firstTs = e.ts; + if (e.ts > row.lastTs) row.lastTs = e.ts; + if (e.details?.cost) row.cost += e.details.cost; + if (e.details?.toolError) row.hasError = true; + if (!row.firstPrompt && e.type === "prompt" && e.details?.fullText) { + row.firstPrompt = e.details.fullText.trim().slice(0, 200); + } + } + const rows = Array.from(byId.values()); + rows.sort((a, b) => (a.lastTs < b.lastTs ? 1 : -1)); + return rows; +} + +/** Classic relative date bucket for session grouping. */ +export function dateBucket(iso: string): "today" | "yesterday" | "7d" | "older" { + const then = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - then.getTime(); + const sameDay = + then.getFullYear() === now.getFullYear() && + then.getMonth() === now.getMonth() && + then.getDate() === now.getDate(); + if (sameDay) return "today"; + if (diffMs < 48 * 3600_000) return "yesterday"; + if (diffMs < 7 * 86400_000) return "7d"; + return "older"; +} + +function extractSubAgent(e: AgentEvent): string | undefined { + const tool = e.tool ?? ""; + // openclaw:content / openclaw:research / openclaw:content:Bash + const m = tool.match(/^openclaw:([^:]+)/); + if (m) return m[1]; + return undefined; +} diff --git a/src/util/recent-writes.ts b/src/util/recent-writes.ts new file mode 100644 index 0000000..8dba9bd --- /dev/null +++ b/src/util/recent-writes.ts @@ -0,0 +1,33 @@ +/** + * Module-scoped cache of paths recently written by an attributed agent. + * fs-watcher consults this before emitting a generic `file_change` event to + * avoid double-counting Claude / OpenClaw / Cursor edits. + */ + +const DEDUPE_WINDOW_MS = 5_000; +const EXPIRY_MS = 30_000; + +const recent = new Map<string, number>(); +let lastSweep = 0; + +export function markAgentWrite(path: string, ts: string | number = Date.now()): void { + const t = typeof ts === "string" ? new Date(ts).getTime() : ts; + if (!path || Number.isNaN(t)) return; + recent.set(path, t); + sweepIfDue(); +} + +export function wasRecentlyWrittenByAgent(path: string): boolean { + const t = recent.get(path); + if (t == null) return false; + return Date.now() - t <= DEDUPE_WINDOW_MS; +} + +function sweepIfDue(): void { + const now = Date.now(); + if (now - lastSweep < EXPIRY_MS) return; + lastSweep = now; + for (const [p, t] of recent) { + if (now - t > EXPIRY_MS) recent.delete(p); + } +} diff --git a/src/util/semantic-builder.ts b/src/util/semantic-builder.ts new file mode 100644 index 0000000..b7108df --- /dev/null +++ b/src/util/semantic-builder.ts @@ -0,0 +1,365 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { translateClaudeLine } from "../adapters/claude-code.js"; +import { translateCodexLine, codexSessionsDir } from "../adapters/codex.js"; +import { translateSession as translateOpenClawSession } from "../adapters/openclaw.js"; +import type { AgentEvent } from "../schema.js"; +import { + indexedIds, + loadEmbedder, + upsertTurns, + type IndexTurn, +} from "./semantic-index.js"; + +/** + * Walks every session file on disk, groups events into turns + * (user prompt + following assistant/tool events up to the next + * prompt), embeds each turn with the local sentence-transformer, + * and writes to the semantic index. + * + * Incremental: already-indexed turn ids are skipped, so calling this + * repeatedly only embeds new material. + */ + +export interface BuildProgress { + scannedFiles: number; + queuedTurns: number; + embeddedTurns: number; + skippedTurns: number; +} + +export async function buildSemanticIndex(opts: { + onProgress?: (p: BuildProgress) => void; + signal?: AbortSignal; +}): Promise<BuildProgress> { + const progress: BuildProgress = { + scannedFiles: 0, + queuedTurns: 0, + embeddedTurns: 0, + skippedTurns: 0, + }; + const already = indexedIds(); + const queue: IndexTurn[] = []; + const home = os.homedir(); + + // Collect turns from Claude + Codex session files. Gemini chats are + // JSON (not JSONL) and use a different shape — indexed via a separate + // branch below. + const claudeRoot = path.join(home, ".claude", "projects"); + const codexRoot = codexSessionsDir(home); + const geminiRoot = path.join(home, ".gemini", "tmp"); + const openclawRoot = path.join(home, ".openclaw", "agents"); + + for (const file of walkJsonl(claudeRoot)) { + if (opts.signal?.aborted) break; + progress.scannedFiles += 1; + collectClaudeTurns(file, already, queue); + } + for (const file of walkJsonl(codexRoot)) { + if (opts.signal?.aborted) break; + progress.scannedFiles += 1; + collectCodexTurns(file, already, queue); + } + for (const file of walkJson(geminiRoot)) { + if (opts.signal?.aborted) break; + progress.scannedFiles += 1; + collectGeminiTurns(file, already, queue); + } + for (const file of walkJsonl(openclawRoot)) { + if (opts.signal?.aborted) break; + // Only index session files (not logs/config-audit). + if (!file.includes(path.sep + "sessions" + path.sep)) continue; + progress.scannedFiles += 1; + collectOpenClawTurns(file, already, queue); + } + + progress.queuedTurns = queue.length; + opts.onProgress?.(progress); + + if (queue.length === 0) return progress; + + const embed = await loadEmbedder(); + + // Embed in small batches to keep memory bounded and let the UI tick. + const BATCH = 32; + for (let i = 0; i < queue.length; i += BATCH) { + if (opts.signal?.aborted) break; + const batch = queue.slice(i, i + BATCH); + const withEmb: (IndexTurn & { embedding: Float32Array })[] = []; + for (const turn of batch) { + const emb = await embed(turn.text.slice(0, 8_000)); + withEmb.push({ ...turn, embedding: new Float32Array(emb) }); + } + upsertTurns(withEmb); + progress.embeddedTurns += withEmb.length; + opts.onProgress?.(progress); + } + + return progress; +} + +// ─── Walkers ──────────────────────────────────────────────────────────── + +function* walkJsonl(root: string): Generator<string> { + if (!fs.existsSync(root)) return; + yield* walkExt(root, ".jsonl"); +} + +function* walkJson(root: string): Generator<string> { + if (!fs.existsSync(root)) return; + yield* walkExt(root, ".json"); +} + +function* walkExt(dir: string, ext: string): Generator<string> { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) yield* walkExt(full, ext); + else if (e.isFile() && e.name.endsWith(ext)) yield full; + } +} + +// ─── Claude + Codex collectors ───────────────────────────────────────── + +function collectClaudeTurns( + file: string, + already: Set<string>, + queue: IndexTurn[], +): void { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return; + } + const sessionId = path.basename(file, ".jsonl"); + const project = projectFromClaudeFile(file); + const events: AgentEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + const ev = translateClaudeLine(obj, sessionId, project); + if (ev) events.push(ev); + } catch { + /* skip malformed */ + } + } + groupAndQueue("claude-code", sessionId, project, events, already, queue); +} + +function collectCodexTurns( + file: string, + already: Set<string>, + queue: IndexTurn[], +): void { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return; + } + const base = path.basename(file, ".jsonl"); + const m = base.match(/rollout-[0-9T:\-.]+-(.+)$/); + const sessionId = m?.[1] ?? base; + let project = ""; + const events: AgentEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.type === "session_meta") { + const cwd = obj.payload?.cwd; + if (typeof cwd === "string") { + project = cwd.split(path.sep).filter(Boolean).pop() ?? ""; + } + continue; + } + const ev = translateCodexLine(obj, sessionId, project); + if (ev) events.push(ev); + } catch { + /* skip */ + } + } + groupAndQueue("codex", sessionId, project, events, already, queue); +} + +function groupAndQueue( + agent: string, + sessionId: string, + project: string, + events: AgentEvent[], + already: Set<string>, + queue: IndexTurn[], +): void { + events.sort((a, b) => (a.ts < b.ts ? -1 : 1)); + let turnIdx = 0; + let current: { prompt?: string; pieces: string[]; ts: string } | null = null; + const push = () => { + if (!current) return; + turnIdx += 1; + const id = `${agent}:${sessionId}:${turnIdx}`; + if (already.has(id)) return; + const text = [current.prompt, ...current.pieces] + .filter(Boolean) + .join("\n\n") + .trim(); + if (!text) return; + queue.push({ + id, + agent, + sessionId, + project, + turnIdx, + timestamp: current.ts, + label: (current.prompt ?? current.pieces[0] ?? "").slice(0, 60).replace(/\s+/g, " ").trim(), + text, + }); + }; + for (const ev of events) { + if (ev.type === "prompt") { + push(); + current = { + prompt: ev.details?.fullText ?? ev.summary, + pieces: [], + ts: ev.ts, + }; + continue; + } + if (!current) current = { pieces: [], ts: ev.ts }; + if (ev.type === "response" && ev.details?.fullText) { + current.pieces.push(ev.details.fullText); + } else if (ev.cmd) { + current.pieces.push(`$ ${ev.cmd}`); + } else if (ev.details?.toolResult) { + current.pieces.push(ev.details.toolResult.slice(0, 2000)); + } else if (ev.summary) { + current.pieces.push(ev.summary); + } + } + push(); +} + +// ─── OpenClaw collector ──────────────────────────────────────────────── + +function collectOpenClawTurns( + file: string, + already: Set<string>, + queue: IndexTurn[], +): void { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return; + } + const sessionId = path.basename(file, ".jsonl"); + // Path shape: ~/.openclaw/agents/<subAgent>/sessions/<id>.jsonl + const parts = file.split(path.sep); + const agentsIdx = parts.lastIndexOf("agents"); + const subAgent = agentsIdx >= 0 ? (parts[agentsIdx + 1] ?? "main") : "main"; + const events: AgentEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + const ev = translateOpenClawSession(obj, subAgent, sessionId); + if (ev) events.push(ev); + } catch { + /* skip malformed */ + } + } + const project = events[0]?.summary?.match(/^\[([^\]]+)\]/)?.[1] ?? subAgent; + groupAndQueue("openclaw", sessionId, project, events, already, queue); +} + +// ─── Gemini collector ────────────────────────────────────────────────── + +function collectGeminiTurns( + file: string, + already: Set<string>, + queue: IndexTurn[], +): void { + let doc: unknown; + try { + doc = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return; + } + if (!doc || typeof doc !== "object") return; + const d = doc as Record<string, unknown>; + const sessionId = + typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"); + const project = geminiProjectFromPath(file); + const messages = Array.isArray(d.messages) ? d.messages : []; + let turnIdx = 0; + let pending: { prompt: string; ts: string } | null = null; + for (const m of messages) { + if (!m || typeof m !== "object") continue; + const msg = m as Record<string, unknown>; + const type = typeof msg.type === "string" ? msg.type : ""; + const ts = typeof msg.timestamp === "string" ? msg.timestamp : ""; + const text = extractGeminiText(msg.content); + if (type === "user") { + pending = { prompt: text, ts }; + continue; + } + if (type === "gemini" && pending) { + turnIdx += 1; + const id = `gemini:${sessionId}:${turnIdx}`; + if (already.has(id)) { + pending = null; + continue; + } + queue.push({ + id, + agent: "gemini", + sessionId, + project, + turnIdx, + timestamp: pending.ts, + label: pending.prompt.slice(0, 60).replace(/\s+/g, " ").trim(), + text: `${pending.prompt}\n\n${text}`.slice(0, 16_000), + }); + pending = null; + } + } +} + +function extractGeminiText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((c) => + c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string" + ? ((c as { text: string }).text) + : "", + ) + .filter(Boolean) + .join("\n"); +} + +// ─── Small path helpers ──────────────────────────────────────────────── + +function projectFromClaudeFile(file: string): string { + const parts = file.split(path.sep); + const idx = parts.lastIndexOf("projects"); + if (idx >= 0 && parts[idx + 1]) { + const segs = parts[idx + 1]!.split("-").filter(Boolean); + return segs[segs.length - 1] ?? parts[idx + 1]!; + } + return ""; +} + +function geminiProjectFromPath(file: string): string { + const parts = file.split(path.sep); + const idx = parts.lastIndexOf("tmp"); + if (idx >= 0 && parts[idx + 1]) return parts[idx + 1]!; + return ""; +} diff --git a/src/util/semantic-index.test.ts b/src/util/semantic-index.test.ts new file mode 100644 index 0000000..ebeca88 --- /dev/null +++ b/src/util/semantic-index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { rrfFuse } from "./semantic-index.js"; + +describe("rrfFuse", () => { + it("prefers documents that appear in both rankings", () => { + const bm = [ + { id: "a", rank: 1 }, + { id: "b", rank: 2 }, + { id: "c", rank: 3 }, + ]; + const vec = [ + { id: "b", rank: 1 }, + { id: "a", rank: 2 }, + { id: "d", rank: 3 }, + ]; + const fused = rrfFuse([{ hits: bm }, { hits: vec }], 60, 10); + // a and b appear in both; c and d only in one. + const top2 = fused.slice(0, 2).map((r) => r.id); + expect(top2).toContain("a"); + expect(top2).toContain("b"); + expect(fused.find((r) => r.id === "c")?.sources.size).toBe(1); + expect(fused.find((r) => r.id === "d")?.sources.size).toBe(1); + }); + + it("respects the k parameter (larger k flattens the curve)", () => { + const bm = [{ id: "a", rank: 1 }]; + const k10 = rrfFuse([{ hits: bm }], 10)[0]!.score; + const k60 = rrfFuse([{ hits: bm }], 60)[0]!.score; + expect(k10).toBeGreaterThan(k60); + }); + + it("drops to empty when no inputs", () => { + expect(rrfFuse([])).toEqual([]); + }); + + it("caps output at limit", () => { + const hits = Array.from({ length: 20 }, (_, i) => ({ + id: `id${i}`, + rank: i + 1, + })); + const fused = rrfFuse([{ hits }], 60, 5); + expect(fused).toHaveLength(5); + }); +}); diff --git a/src/util/semantic-index.ts b/src/util/semantic-index.ts new file mode 100644 index 0000000..549b661 --- /dev/null +++ b/src/util/semantic-index.ts @@ -0,0 +1,321 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; +import type { Database as DB } from "better-sqlite3"; + +/** + * Local-only semantic + lexical index for agent sessions. + * + * BM25 (SQLite FTS5) ─┐ + * ├─ Reciprocal Rank Fusion (k=60) → ranked turns + * Vector cosine ─────┘ + * + * Embeddings: BAAI/bge-small-en-v1.5 (384-dim, ~80 MB ONNX download on + * first run, cached at ~/.agentwatch/models/). Loaded lazily via + * @huggingface/transformers v3 (ONNX runtime WASM on Node, no native + * build step). bge-small-en-v1.5 scores 3-5 points higher than + * all-MiniLM-L6-v2 on MTEB short-text retrieval tasks at the same + * parameter size. + * + * Storage: ~/.agentwatch/index.sqlite (FTS5 virtual table + a plain + * table of Float32Array embeddings stored as BLOB). For the ~10k turn + * scale of a heavy user, brute-force cosine is under 100ms per query + * and avoids pulling in sqlite-vec as a native extension. + */ + +const DB_DIR = path.join(os.homedir(), ".agentwatch"); +const DB_PATH = path.join(DB_DIR, "index.sqlite"); +export const MODEL_ID = "Xenova/bge-small-en-v1.5"; +export const EMBED_DIM = 384; + +let db: DB | null = null; +let embedderPromise: Promise<EmbedFn> | null = null; + +type EmbedFn = (text: string) => Promise<Float32Array>; + +export interface IndexTurn { + /** `<agent>:<sessionId>:<turnIdx>` — our unique key for the turn. */ + id: string; + agent: string; + sessionId: string; + project: string; + turnIdx: number; + timestamp: string; + /** Short label for the UI (first ~60 chars of the user prompt). */ + label: string; + /** Concatenated user+assistant text used for both FTS and embedding. */ + text: string; +} + +export interface SearchHit { + id: string; + agent: string; + sessionId: string; + project: string; + turnIdx: number; + timestamp: string; + label: string; + /** RRF fused score (higher = better). */ + score: number; + /** Which sub-search contributed: B = BM25 only, V = vector only, H = both. */ + source: "B" | "V" | "H"; +} + +function openDb(): DB { + if (db) return db; + fs.mkdirSync(DB_DIR, { recursive: true }); + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5( + id UNINDEXED, + agent UNINDEXED, + session UNINDEXED, + project UNINDEXED, + turn_idx UNINDEXED, + timestamp UNINDEXED, + label UNINDEXED, + text, + tokenize = 'porter unicode61' + ); + CREATE TABLE IF NOT EXISTS turns_vec ( + id TEXT PRIMARY KEY, + embedding BLOB NOT NULL + ); + `); + return db; +} + +export function _resetForTest(): void { + db?.close(); + db = null; + embedderPromise = null; +} + +/** Lazy-load the sentence-embedding model. First call downloads the ONNX + * weights (~80 MB) to the transformers.js cache under + * ~/.agentwatch/models. Subsequent calls reuse the in-memory pipeline. */ +export async function loadEmbedder(): Promise<EmbedFn> { + if (embedderPromise) return embedderPromise; + embedderPromise = (async () => { + const cacheDir = path.join(DB_DIR, "models"); + fs.mkdirSync(cacheDir, { recursive: true }); + const mod: { + pipeline: ( + task: string, + model: string, + opts?: unknown, + ) => Promise< + (text: string, opts?: unknown) => Promise<{ data: Float32Array }> + >; + env: Record<string, unknown>; + } = (await import("@huggingface/transformers")) as unknown as { + pipeline: typeof mod.pipeline; + env: Record<string, unknown>; + }; + mod.env.cacheDir = cacheDir; + mod.env.allowLocalModels = false; + const extractor = await mod.pipeline("feature-extraction", MODEL_ID, { + dtype: "q8", // int8-quantized — 4× smaller, ~95% of float32 quality + }); + return async (text: string): Promise<Float32Array> => { + const res = await extractor(text, { pooling: "mean", normalize: true }); + return res.data as Float32Array; + }; + })(); + return embedderPromise; +} + +export function hasIndex(): boolean { + return fs.existsSync(DB_PATH); +} + +export function indexStats(): { turns: number; vectors: number } { + const d = openDb(); + const turns = (d.prepare("SELECT COUNT(*) as n FROM turns_fts").get() as { + n: number; + }).n; + const vectors = (d.prepare("SELECT COUNT(*) as n FROM turns_vec").get() as { + n: number; + }).n; + return { turns, vectors }; +} + +/** Return the set of turn ids already indexed, so callers can skip them. */ +export function indexedIds(): Set<string> { + const d = openDb(); + const rows = d.prepare("SELECT id FROM turns_fts").all() as { id: string }[]; + return new Set(rows.map((r) => r.id)); +} + +/** Insert a batch of turns. Embeddings are computed in the caller so + * the indexer can show progress; this function just writes. */ +export function upsertTurns( + rows: (IndexTurn & { embedding: Float32Array })[], +): void { + const d = openDb(); + const insFts = d.prepare( + `INSERT INTO turns_fts (id, agent, session, project, turn_idx, timestamp, label, text) + VALUES (@id, @agent, @sessionId, @project, @turnIdx, @timestamp, @label, @text)`, + ); + const insVec = d.prepare( + `INSERT OR REPLACE INTO turns_vec (id, embedding) VALUES (?, ?)`, + ); + const del = d.prepare(`DELETE FROM turns_fts WHERE id = ?`); + const tx = d.transaction((batch: typeof rows) => { + for (const r of batch) { + del.run(r.id); // FTS5 has no upsert; delete-then-insert + insFts.run(r); + insVec.run(r.id, Buffer.from(r.embedding.buffer)); + } + }); + tx(rows); +} + +export interface BmHit { + id: string; + rank: number; // 1-based +} + +/** Plain BM25 search via FTS5. Returns id + rank. */ +export function searchBm25(query: string, limit: number): BmHit[] { + const d = openDb(); + // Quote the query so FTS5 treats it as a phrase-ish match. Users who + // want operators can escape with `query:"..."`. + const fts = quoteForFts(query); + const rows = d + .prepare( + `SELECT id FROM turns_fts WHERE turns_fts MATCH ? ORDER BY bm25(turns_fts) LIMIT ?`, + ) + .all(fts, limit) as { id: string }[]; + return rows.map((r, i) => ({ id: r.id, rank: i + 1 })); +} + +function quoteForFts(q: string): string { + // Split on whitespace, drop non-alphanumeric, then AND the terms. + const terms = q + .split(/\s+/) + .map((t) => t.replace(/[^\p{L}\p{N}_]/gu, "")) + .filter(Boolean); + if (terms.length === 0) return '""'; + return terms.map((t) => `"${t}"`).join(" AND "); +} + +/** Plain vector search: brute-force cosine against every stored + * embedding. Fine for < ~100k rows on a dev laptop. Returns id + rank. */ +export function searchVector(queryVec: Float32Array, limit: number): BmHit[] { + const d = openDb(); + const rows = d.prepare("SELECT id, embedding FROM turns_vec").all() as { + id: string; + embedding: Buffer; + }[]; + const scored: { id: string; score: number }[] = []; + for (const r of rows) { + const vec = new Float32Array( + r.embedding.buffer, + r.embedding.byteOffset, + r.embedding.byteLength / 4, + ); + if (vec.length !== queryVec.length) continue; + // Both vectors are L2-normalized by the pipeline so the dot product + // is the cosine similarity. + let dot = 0; + for (let i = 0; i < vec.length; i++) dot += vec[i]! * queryVec[i]!; + scored.push({ id: r.id, score: dot }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map((s, i) => ({ id: s.id, rank: i + 1 })); +} + +/** Reciprocal Rank Fusion. k=60 per Cormack et al. 2009. */ +export function rrfFuse( + results: { hits: BmHit[]; weight?: number }[], + k = 60, + limit = 50, +): { id: string; score: number; sources: Set<number> }[] { + const acc = new Map< + string, + { id: string; score: number; sources: Set<number> } + >(); + for (let idx = 0; idx < results.length; idx++) { + const { hits, weight = 1 } = results[idx]!; + for (const h of hits) { + const prev = acc.get(h.id); + const contrib = weight / (k + h.rank); + if (prev) { + prev.score += contrib; + prev.sources.add(idx); + } else { + acc.set(h.id, { + id: h.id, + score: contrib, + sources: new Set([idx]), + }); + } + } + } + return Array.from(acc.values()) + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + +/** Fetch the full row data for a set of ids, in the ranking order. */ +export function enrichHits( + ranked: { id: string; score: number; sources: Set<number> }[], +): SearchHit[] { + if (ranked.length === 0) return []; + const d = openDb(); + const placeholders = ranked.map(() => "?").join(","); + const rows = d + .prepare( + `SELECT id, agent, session as sessionId, project, turn_idx as turnIdx, + timestamp, label FROM turns_fts WHERE id IN (${placeholders})`, + ) + .all(...ranked.map((r) => r.id)) as Record<string, unknown>[]; + const byId = new Map<string, Record<string, unknown>>(); + for (const r of rows) byId.set(r.id as string, r); + return ranked + .map((r) => { + const row = byId.get(r.id); + if (!row) return null; + const both = r.sources.has(0) && r.sources.has(1); + const src: "B" | "V" | "H" = both + ? "H" + : r.sources.has(0) + ? "B" + : "V"; + return { + id: r.id, + agent: row.agent as string, + sessionId: row.sessionId as string, + project: row.project as string, + turnIdx: row.turnIdx as number, + timestamp: row.timestamp as string, + label: row.label as string, + score: r.score, + source: src, + } satisfies SearchHit; + }) + .filter((x): x is SearchHit => x !== null); +} + +/** One-shot hybrid search. Caller must have embedded the query. */ +export async function searchHybrid( + query: string, + queryVec: Float32Array, + limit: number, +): Promise<SearchHit[]> { + const bm = searchBm25(query, limit * 2); + const vec = searchVector(queryVec, limit * 2); + const fused = rrfFuse([{ hits: bm }, { hits: vec }], 60, limit); + return enrichHits(fused); +} + +/** BM25-only fallback for when the embedder is still loading or was + * never initialized. */ +export function searchBm25Only(query: string, limit: number): SearchHit[] { + const bm = searchBm25(query, limit); + const fused = rrfFuse([{ hits: bm }], 60, limit); + return enrichHits(fused); +} diff --git a/src/util/shutdown.test.ts b/src/util/shutdown.test.ts new file mode 100644 index 0000000..ea73c15 --- /dev/null +++ b/src/util/shutdown.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetShutdownForTest, + onShutdown, + runShutdownHooks, +} from "./shutdown.js"; + +describe("shutdown registry", () => { + beforeEach(() => { + _resetShutdownForTest(); + }); + + it("runs hooks in LIFO order", async () => { + const calls: number[] = []; + onShutdown(() => { + calls.push(1); + }); + onShutdown(() => { + calls.push(2); + }); + onShutdown(() => { + calls.push(3); + }); + await runShutdownHooks(); + expect(calls).toEqual([3, 2, 1]); + }); + + it("continues when a hook throws", async () => { + const calls: string[] = []; + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + onShutdown(() => { + calls.push("first"); + }); + onShutdown(() => { + throw new Error("boom"); + }); + onShutdown(() => { + calls.push("third"); + }); + await runShutdownHooks(); + expect(calls).toEqual(["third", "first"]); + spy.mockRestore(); + }); + + it("awaits async hooks", async () => { + let done = false; + onShutdown(async () => { + await new Promise((r) => setTimeout(r, 5)); + done = true; + }); + await runShutdownHooks(); + expect(done).toBe(true); + }); + + it("is idempotent on re-entry", async () => { + let count = 0; + onShutdown(() => { + count += 1; + }); + await Promise.all([runShutdownHooks(), runShutdownHooks()]); + expect(count).toBe(1); + }); + + it("unregister removes a hook before it runs", async () => { + let ran = false; + const off = onShutdown(() => { + ran = true; + }); + off(); + await runShutdownHooks(); + expect(ran).toBe(false); + }); +}); diff --git a/src/util/shutdown.ts b/src/util/shutdown.ts new file mode 100644 index 0000000..17833d7 --- /dev/null +++ b/src/util/shutdown.ts @@ -0,0 +1,50 @@ +/** + * Process-wide shutdown registry. + * + * Adapters and the web server register synchronous cleanup functions + * here. When a signal arrives (SIGINT / SIGTERM / SIGHUP) or the TUI + * exits, `runShutdownHooks` drains the registry in LIFO order so + * chokidar watchers close, better-sqlite3 handles flush, and SSE + * sockets get a clean `end()` before the process dies. + * + * Previously: adapters were started inside React useEffect and only + * torn down by the unmount path. A SIGINT-driven `process.exit(0)` + * skipped React entirely, orphaning fs watchers and SQLite readers + * (and, on some systems, leaving the terminal in alt-screen mode). + */ + +type Hook = () => void | Promise<void>; + +const hooks: Hook[] = []; +let draining = false; + +export function onShutdown(fn: Hook): () => void { + hooks.push(fn); + return () => { + const i = hooks.lastIndexOf(fn); + if (i !== -1) hooks.splice(i, 1); + }; +} + +/** Run every registered hook, newest first, swallowing per-hook errors + * so one failing adapter doesn't strand the others. Idempotent — a + * second call is a no-op (a signal can arrive during cleanup). */ +export async function runShutdownHooks(): Promise<void> { + if (draining) return; + draining = true; + while (hooks.length > 0) { + const fn = hooks.pop()!; + try { + await fn(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[agentwatch] shutdown hook failed:", err); + } + } +} + +/** Test helper — reset state between specs. */ +export function _resetShutdownForTest(): void { + hooks.length = 0; + draining = false; +} diff --git a/src/util/spawn-tracker.test.ts b/src/util/spawn-tracker.test.ts new file mode 100644 index 0000000..886848f --- /dev/null +++ b/src/util/spawn-tracker.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + _pendingSpawns, + _resetSpawnTracker, + consumeSpawn, + registerSpawn, +} from "./spawn-tracker.js"; + +describe("spawn-tracker", () => { + beforeEach(() => _resetSpawnTracker()); + + it("links a registered spawn when the child agent matches", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + const hit = consumeSpawn("codex", "/tmp/work", 5_000); + expect(hit?.parentEventId).toBe("p1"); + }); + + it("returns null when the callee differs", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + expect(consumeSpawn("gemini", "/tmp/work", 5_000)).toBeNull(); + }); + + it("returns null when the cwd differs", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/a", + registeredMs: 1_000, + }); + expect(consumeSpawn("codex", "/tmp/b", 5_000)).toBeNull(); + }); + + it("treats an empty cwd as a wildcard (Gemini chat-json fallback)", () => { + registerSpawn({ + parentEventId: "p1", + callee: "gemini", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + // Gemini child sessions don't carry cwd; we match on callee alone. + expect(consumeSpawn("gemini", "", 5_000)?.parentEventId).toBe("p1"); + }); + + it("matches when cwds are prefix-related (subdirectory case)", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + // Child reports a subdir of the parent's cwd. + expect( + consumeSpawn("codex", "/tmp/work/subdir", 5_000)?.parentEventId, + ).toBe("p1"); + }); + + it("removes the matched spawn so a second child doesn't double-link", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + expect(consumeSpawn("codex", "/tmp/work", 5_000)?.parentEventId).toBe("p1"); + expect(consumeSpawn("codex", "/tmp/work", 5_000)).toBeNull(); + }); + + it("drops entries older than the 60s TTL", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 0, + }); + // 61 seconds later — out of TTL. + expect(consumeSpawn("codex", "/tmp/work", 61_000)).toBeNull(); + }); + + it("returns the most recent match when multiple parents are pending", () => { + registerSpawn({ + parentEventId: "p1", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 1_000, + }); + registerSpawn({ + parentEventId: "p2", + callee: "codex", + cwd: "/tmp/work", + registeredMs: 2_000, + }); + expect(consumeSpawn("codex", "/tmp/work", 3_000)?.parentEventId).toBe("p2"); + expect(consumeSpawn("codex", "/tmp/work", 3_000)?.parentEventId).toBe("p1"); + }); + + it("prunes the buffer once it exceeds the size cap", () => { + for (let i = 0; i < 250; i++) { + registerSpawn({ + parentEventId: `p${i}`, + callee: "codex", + cwd: "/tmp/work", + registeredMs: i, + }); + } + expect(_pendingSpawns().length).toBeLessThanOrEqual(200); + }); +}); diff --git a/src/util/spawn-tracker.ts b/src/util/spawn-tracker.ts new file mode 100644 index 0000000..e62b92e --- /dev/null +++ b/src/util/spawn-tracker.ts @@ -0,0 +1,93 @@ +import type { AgentName } from "../schema.js"; + +/** + * Tracks recent `Bash(<agent-cli>)` invocations so that when the + * spawned child agent's session_meta lands a few seconds later, we can + * link the child session back to the parent event id. The parent + * explicitly named the child (we matched the binary in agent-call.ts), + * so the false-positive rate is low — much narrower than the cancelled + * AUR-183 cross-agent-correlation work that tried to correlate + * arbitrary agent pairs by heuristics. + * + * In-process state only. Bounded ring buffer with a TTL — old entries + * fall out so a stale parent can't sit around for hours waiting for an + * unrelated session to start with the same cwd. + */ + +export interface PendingSpawn { + /** AgentEvent id of the parent Bash(<agent-cli>) event. */ + parentEventId: string; + /** The child agent the parent invoked (codex / gemini / etc). */ + callee: AgentName; + /** cwd captured from the parent's session_meta — used to disambiguate + * multiple in-flight calls to the same agent. */ + cwd: string; + /** Wall-clock ms when the parent event was emitted. */ + registeredMs: number; +} + +const TTL_MS = 60_000; +const MAX_SIZE = 200; + +const pending: PendingSpawn[] = []; + +export function registerSpawn(entry: PendingSpawn): void { + pending.push(entry); + prune(entry.registeredMs); +} + +/** Find the most recent matching spawn for `(callee, cwd)`. Removes it + * from the queue so two consecutive sessions with the same cwd don't + * both link to the first parent. Returns null when no match within TTL. */ +export function consumeSpawn( + callee: AgentName, + cwd: string, + nowMs: number = Date.now(), +): PendingSpawn | null { + prune(nowMs); + // Walk newest-first so we link to the most recent parent — matches + // human intuition when the same caller fired several times in a row. + for (let i = pending.length - 1; i >= 0; i--) { + const candidate = pending[i]!; + if (candidate.callee !== callee) continue; + if (!cwdMatches(candidate.cwd, cwd)) continue; + pending.splice(i, 1); + return candidate; + } + return null; +} + +/** Drop everything older than TTL_MS or beyond the size cap. */ +function prune(nowMs: number): void { + while (pending.length > 0 && nowMs - pending[0]!.registeredMs > TTL_MS) { + pending.shift(); + } + while (pending.length > MAX_SIZE) { + pending.shift(); + } +} + +/** Two cwds match if they're equal, one is a prefix path of the other + * (handles symlinks / `~/` expansion / monorepo subdirs), OR either + * side is empty. The empty-side case is a deliberate wildcard for + * child agents whose session files don't carry cwd (Gemini chat + * JSON). The 60s TTL bounds the false-positive blast radius — two + * concurrent council invocations across separate workspaces is the + * pathological case we accept. */ +function cwdMatches(a: string, b: string): boolean { + if (!a || !b) return true; + if (a === b) return true; + const aTrim = a.replace(/\/+$/, ""); + const bTrim = b.replace(/\/+$/, ""); + return aTrim === bTrim || aTrim.startsWith(bTrim + "/") || bTrim.startsWith(aTrim + "/"); +} + +/** Test helper — wipes the queue between tests. */ +export function _resetSpawnTracker(): void { + pending.length = 0; +} + +/** Test / debug helper — read-only view of the queue. */ +export function _pendingSpawns(): readonly PendingSpawn[] { + return pending; +} diff --git a/src/util/terminal.ts b/src/util/terminal.ts new file mode 100644 index 0000000..9ae9249 --- /dev/null +++ b/src/util/terminal.ts @@ -0,0 +1,23 @@ +/** Leaves the alternate screen buffer (if we entered it in index.tsx) and + * switches stdin back out of raw mode. Must run BEFORE any `process.exit` + * call, otherwise the shell inherits a raw-mode TTY and echo/cursor state + * stays broken until the user runs `stty sane`. */ +export function restoreTerminal(): void { + try { + if (process.stdout.isTTY) process.stdout.write("\x1b[?1049l"); + } catch { + /* ignore */ + } + try { + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(false); + } + } catch { + /* ignore */ + } + try { + process.stdin.pause(); + } catch { + /* ignore */ + } +} diff --git a/src/util/token-attribution.test.ts b/src/util/token-attribution.test.ts new file mode 100644 index 0000000..9b32073 --- /dev/null +++ b/src/util/token-attribution.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + attributeTokens, + attributeTurns, + approxTokens, + countTokens, + _resetMemoryFileCache, +} from "./token-attribution.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: Math.random().toString(36).slice(2), + ts: o.ts ?? "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "response", + riskScore: 0, + sessionId: "s1", + ...o, +}); + +beforeEach(() => _resetMemoryFileCache()); + +describe("countTokens", () => { + it("returns 0 for empty string", () => { + expect(countTokens("")).toBe(0); + }); + + it("returns a positive integer for non-empty text", () => { + const n = countTokens("The quick brown fox jumps over the lazy dog."); + expect(n).toBeGreaterThan(0); + expect(Number.isInteger(n)).toBe(true); + }); + + it("scales roughly linearly with repeated text", () => { + const small = countTokens("hello"); + const big = countTokens("hello ".repeat(100)); + expect(big).toBeGreaterThan(small * 50); + }); +}); + +describe("approxTokens (legacy char-based)", () => { + it("returns chars/4 rounded up", () => { + expect(approxTokens("abcd")).toBe(1); + expect(approxTokens("abcde")).toBe(2); + expect(approxTokens("")).toBe(0); + }); +}); + +describe("attributeTurns", () => { + it("produces one breakdown per assistant turn with preceding prompt attributed", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tok-")); + const events: AgentEvent[] = [ + evt({ + type: "prompt", + ts: "2026-04-15T10:00:00Z", + details: { fullText: "write a sorting algorithm" }, + }), + evt({ + type: "response", + ts: "2026-04-15T10:00:05Z", + details: { + usage: { input: 1000, cacheRead: 500, cacheCreate: 0, output: 200 }, + cost: 0.01, + thinking: "Thinking about quick-sort vs merge-sort.", + }, + }), + ]; + const turns = attributeTurns(events, "s1", tmp); + expect(turns).toHaveLength(1); + expect(turns[0]!.user).toBeGreaterThan(0); + expect(turns[0]!.thinking).toBeGreaterThan(0); + expect(turns[0]!.input).toBe(1000); + expect(turns[0]!.cost).toBe(0.01); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("folds tool I/O into the next assistant turn", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tok-")); + const events: AgentEvent[] = [ + evt({ + type: "prompt", + ts: "2026-04-15T10:00:00Z", + details: { fullText: "list files" }, + }), + evt({ + type: "shell_exec", + ts: "2026-04-15T10:00:01Z", + details: { toolResult: "file1.ts\nfile2.ts\nfile3.ts" }, + }), + evt({ + type: "response", + ts: "2026-04-15T10:00:02Z", + details: { + usage: { input: 100, cacheRead: 0, cacheCreate: 0, output: 50 }, + }, + }), + ]; + const turns = attributeTurns(events, "s1", tmp); + expect(turns[0]!.toolIO).toBeGreaterThan(0); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("includes CLAUDE.md tokens when present in cwd", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tok-")); + fs.writeFileSync( + path.join(tmp, "CLAUDE.md"), + "# Project memory\n\nThis is project-specific Claude context.", + ); + const events: AgentEvent[] = [ + evt({ + type: "response", + details: { + usage: { input: 100, cacheRead: 0, cacheCreate: 0, output: 0 }, + }, + }), + ]; + const turns = attributeTurns(events, "s1", tmp); + expect(turns[0]!.memoryFile).toBeGreaterThan(0); + fs.rmSync(tmp, { recursive: true, force: true }); + }); +}); + +describe("attributeTokens (aggregate)", () => { + it("sums per-turn categories and reports single claudeMd value", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tok-")); + const events: AgentEvent[] = [ + evt({ + type: "response", + ts: "2026-04-15T10:00:00Z", + details: { + usage: { input: 100, cacheCreate: 0, cacheRead: 0, output: 50 }, + cost: 0.005, + }, + }), + evt({ + type: "response", + ts: "2026-04-15T10:01:00Z", + details: { + usage: { input: 200, cacheCreate: 0, cacheRead: 50, output: 30 }, + cost: 0.003, + }, + }), + ]; + const agg = attributeTokens(events, "s1", tmp); + expect(agg.input).toBe(300); + expect(agg.output).toBe(80); + expect(agg.cost).toBeCloseTo(0.008); + expect(agg.turns).toBe(2); + fs.rmSync(tmp, { recursive: true, force: true }); + }); +}); diff --git a/src/util/token-attribution.ts b/src/util/token-attribution.ts new file mode 100644 index 0000000..93e808f --- /dev/null +++ b/src/util/token-attribution.ts @@ -0,0 +1,180 @@ +import { encode as tokenize } from "gpt-tokenizer"; +import type { AgentEvent, AgentName } from "../schema.js"; +import { memoryFilesFor } from "./memory-file.js"; + +export interface TokenBreakdown { + input: number; + cacheCreate: number; + cacheRead: number; + output: number; + /** Thinking tokens (tokenizer-measured). */ + thinking: number; + /** Tool I/O tokens (toolResult + toolInput, tokenizer-measured). */ + toolIO: number; + /** User-text tokens (prompt fullText, tokenizer-measured). */ + user: number; + /** Tokens for the agent's project memory file(s) (tokenizer-measured). + * Source varies per agent: CLAUDE.md, AGENTS.md, GEMINI.md, + * .cursorrules, .windsurfrules, CONVENTIONS.md, OPENCLAW.md. */ + memoryFile: number; + /** Total cost in USD summed across assistant turns. */ + cost: number; + /** Number of assistant turns contributing to these counts. */ + turns: number; +} + +export interface TurnBreakdown { + turnIdx: number; + ts: string; + sessionId: string; + model?: string; + /** Tokens in each category for this single turn. */ + user: number; + thinking: number; + toolIO: number; + memoryFile: number; + input: number; + cacheRead: number; + cacheCreate: number; + output: number; + cost: number; +} + +const memoryCache = new Map<string, number>(); + +/** Read the agent's memory file(s) (if present) and tokenize once per + * (agent, cwd) pair. Used as a per-turn attribution for turns that + * include the agent's system memory. */ +export function memoryFileTokens( + agent: AgentName, + cwd: string = process.cwd(), +): number { + const key = `${agent}|${cwd}`; + const hit = memoryCache.get(key); + if (hit !== undefined) return hit; + const info = memoryFilesFor(agent, cwd); + const tokens = info.text ? countTokens(info.text) : 0; + memoryCache.set(key, tokens); + return tokens; +} + +export function _resetMemoryFileCache(): void { + memoryCache.clear(); +} + +/** Real tokenizer. Uses gpt-tokenizer (cl100k_base — OpenAI's vocab); + * Claude uses a similar-but-not-identical tokenizer and typically + * counts ~5% more tokens. Close enough to communicate relative weight. + * The UI labels this as "approximate for Claude" so users know. */ +export function countTokens(text: string): number { + if (!text) return 0; + try { + return tokenize(text).length; + } catch { + // Tokenizer blew up on some odd input; fall back to char-based. + return Math.ceil(text.length / 4); + } +} + +/** Legacy approximation; retained for tests asserting the old behaviour + * on non-tokenized text. */ +export function approxTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** One TurnBreakdown per assistant turn in the session, chronologically + * ordered. User-text tokens come from the *preceding* prompt event (the + * one that triggered this turn). Tool I/O is the sum of toolResult + + * toolInput on the turn + any tool events that ran as part of it. */ +export function attributeTurns( + events: AgentEvent[], + sessionId: string, + cwd: string = process.cwd(), +): TurnBreakdown[] { + const inSession = events + .filter((e) => e.sessionId === sessionId) + .sort((a, b) => (a.ts < b.ts ? -1 : 1)); + const agentName = inSession[0]?.agent ?? "unknown"; + const memoryFile = memoryFileTokens(agentName, cwd); + const breakdowns: TurnBreakdown[] = []; + let pendingUserTokens = 0; + let pendingToolIO = 0; + let turnIdx = 0; + + for (const e of inSession) { + if (e.type === "prompt" && e.details?.fullText) { + pendingUserTokens += countTokens(e.details.fullText); + continue; + } + // Non-assistant tool rows (e.g. file_read echoed from fs-watcher) + // aren't attributed to a specific turn, so fold their I/O into the + // next assistant turn. + const d = e.details; + if (!d) continue; + if (d.toolResult) pendingToolIO += countTokens(d.toolResult); + if (d.toolInput) { + pendingToolIO += countTokens(JSON.stringify(d.toolInput)); + } + if (!d.usage) continue; + + turnIdx += 1; + const thinking = d.thinking ? countTokens(d.thinking) : 0; + breakdowns.push({ + turnIdx, + ts: e.ts, + sessionId, + model: d.model, + user: pendingUserTokens, + thinking, + toolIO: pendingToolIO, + memoryFile, + input: d.usage.input, + cacheRead: d.usage.cacheRead, + cacheCreate: d.usage.cacheCreate, + output: d.usage.output, + cost: d.cost ?? 0, + }); + pendingUserTokens = 0; + pendingToolIO = 0; + } + return breakdowns; +} + +/** Attribute a session's token footprint across categories. Aggregate + * of the per-turn breakdown. */ +export function attributeTokens( + events: AgentEvent[], + sessionId: string, + cwd: string = process.cwd(), +): TokenBreakdown { + const turns = attributeTurns(events, sessionId, cwd); + const out: TokenBreakdown = { + input: 0, + cacheCreate: 0, + cacheRead: 0, + output: 0, + thinking: 0, + toolIO: 0, + user: 0, + memoryFile: 0, + cost: 0, + turns: turns.length, + }; + for (const t of turns) { + out.input += t.input; + out.cacheCreate += t.cacheCreate; + out.cacheRead += t.cacheRead; + out.output += t.output; + out.thinking += t.thinking; + out.toolIO += t.toolIO; + out.user += t.user; + out.cost += t.cost; + } + // Memory file is a constant overhead — include it once, not per turn. + out.memoryFile = turns.length > 0 ? turns[0]!.memoryFile : 0; + return out; +} + +export function totalTokens(b: TokenBreakdown): number { + return b.input + b.cacheRead + b.cacheCreate + b.output; +} diff --git a/src/util/triggers.test.ts b/src/util/triggers.test.ts new file mode 100644 index 0000000..286a1bf --- /dev/null +++ b/src/util/triggers.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { compileTriggers, evalTriggers } from "./triggers.js"; +import type { AgentEvent } from "../schema.js"; + +const evt = (o: Partial<AgentEvent>): AgentEvent => ({ + id: "x", + ts: "2026-04-15T10:00:00Z", + agent: "claude-code", + type: "shell_exec", + riskScore: 0, + ...o, +}); + +describe("compileTriggers", () => { + it("drops entries missing title or body", () => { + const c = compileTriggers([ + { title: "x" }, // no body + { body: "x" }, // no title + { title: "ok", body: "ok" }, + ]); + expect(c).toHaveLength(1); + }); + + it("skips invalid regex without throwing", () => { + const c = compileTriggers([ + { match: "[invalid", title: "t", body: "b" }, + { match: "^ok$", title: "t", body: "b" }, + ]); + expect(c).toHaveLength(1); + expect(c[0]!.matchRe?.source).toBe("^ok$"); + }); +}); + +describe("evalTriggers", () => { + it("fires on matching regex and expands {{placeholders}}", () => { + const triggers = compileTriggers([ + { + match: "curl .* \\| bash", + title: "pipe-to-bash", + body: "{{agent}}: {{cmd}}", + }, + ]); + const hit = evalTriggers( + evt({ cmd: "curl https://x | bash" }), + triggers, + ); + expect(hit?.title).toBe("pipe-to-bash"); + expect(hit?.body).toBe("claude-code: curl https://x | bash"); + }); + + it("respects type filter", () => { + const triggers = compileTriggers([ + { type: "file_write", match: ".", title: "w", body: "b" }, + ]); + expect(evalTriggers(evt({ type: "shell_exec" }), triggers)).toBeNull(); + expect( + evalTriggers(evt({ type: "file_write", path: "/a" }), triggers), + ).not.toBeNull(); + }); + + it("respects thresholdUsd", () => { + const triggers = compileTriggers([ + { thresholdUsd: 1, title: "expensive", body: "{{cost}}" }, + ]); + expect( + evalTriggers( + evt({ details: { cost: 0.1 } }), + triggers, + ), + ).toBeNull(); + const hit = evalTriggers( + evt({ details: { cost: 2.5 } }), + triggers, + ); + expect(hit?.body).toBe("$2.5000"); + }); + + it("uses pathMatch for narrower path rules", () => { + const triggers = compileTriggers([ + { pathMatch: "^/etc/", title: "etc", body: "{{path}}" }, + ]); + expect( + evalTriggers(evt({ type: "file_write", path: "/tmp/x" }), triggers), + ).toBeNull(); + const hit = evalTriggers( + evt({ type: "file_write", path: "/etc/passwd" }), + triggers, + ); + expect(hit?.body).toBe("/etc/passwd"); + }); + + it("returns null when no triggers match", () => { + expect(evalTriggers(evt({}), [])).toBeNull(); + }); +}); diff --git a/src/util/triggers.ts b/src/util/triggers.ts new file mode 100644 index 0000000..4b1c655 --- /dev/null +++ b/src/util/triggers.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import chokidar from "chokidar"; +import type { AgentEvent, EventType } from "../schema.js"; + +/** + * User-defined notification triggers. Lives in ~/.agentwatch/triggers.json + * as an array of rule objects. Rules are compiled once on module load; + * restart agentwatch to pick up edits. + * + * Example triggers.json: + * [ + * { "match": "curl .* \\| bash", "title": "pipe-to-bash", + * "body": "{{agent}}: {{cmd}}" }, + * { "type": "file_write", "pathMatch": "^/etc/", "title": "etc write", + * "body": "{{agent}} → {{path}}" } + * ] + */ + +export interface UserTrigger { + /** Regex tested against event.summary, event.cmd, event.path. */ + match?: string; + /** Regex tested against event.path only (narrower than `match`). */ + pathMatch?: string; + /** Limit rule to a specific event type (optional). */ + type?: EventType; + /** Minimum per-turn cost to fire (USD). */ + thresholdUsd?: number; + /** Notification title. `{{token}}` placeholders substituted from event. */ + title: string; + /** Notification body. Same placeholder syntax. */ + body: string; +} + +interface CompiledTrigger extends UserTrigger { + matchRe?: RegExp; + pathRe?: RegExp; +} + +export const TRIGGERS_PATH = path.join(os.homedir(), ".agentwatch", "triggers.json"); + +let cached: CompiledTrigger[] | null = null; + +/** Parse a raw triggers array. Exported for unit tests. */ +export function compileTriggers(raw: unknown): CompiledTrigger[] { + if (!Array.isArray(raw)) return []; + const out: CompiledTrigger[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const r = item as UserTrigger; + if (!r.title || !r.body) continue; + const compiled: CompiledTrigger = { ...r }; + try { + if (r.match) compiled.matchRe = new RegExp(r.match); + if (r.pathMatch) compiled.pathRe = new RegExp(r.pathMatch); + } catch { + continue; // skip bad regex + } + out.push(compiled); + } + return out; +} + +/** Load + compile the user's triggers file. Cached on first call. */ +export function loadTriggers(): CompiledTrigger[] { + if (cached !== null) return cached; + try { + const raw = fs.readFileSync(TRIGGERS_PATH, "utf8"); + cached = compileTriggers(JSON.parse(raw)); + } catch { + cached = []; + } + return cached; +} + +let watcher: ReturnType<typeof chokidar.watch> | null = null; + +/** Start watching the triggers file so edits take effect without a + * restart. Idempotent. No-op until the user creates the file. */ +export function watchTriggers(): () => void { + if (watcher) return () => void watcher?.close(); + try { + watcher = chokidar.watch(TRIGGERS_PATH, { + persistent: true, + ignoreInitial: true, + }); + watcher.on("change", () => _resetTriggersCache()); + watcher.on("add", () => _resetTriggersCache()); + watcher.on("unlink", () => _resetTriggersCache()); + watcher.on("error", () => { + /* swallow — triggers are a convenience, not load-bearing */ + }); + } catch { + /* chokidar failed to spin up; run without live-reload */ + } + return () => { + void watcher?.close(); + watcher = null; + }; +} + +/** Test helper — resets the cache so tests can load different configs. */ +export function _resetTriggersCache(): void { + cached = null; +} + +/** Evaluate every trigger against an event, return the first match as a + * {title, body} pair with `{{token}}` placeholders substituted. */ +export function evalTriggers( + event: AgentEvent, + triggers: CompiledTrigger[] = loadTriggers(), +): { title: string; body: string } | null { + for (const t of triggers) { + if (t.type && event.type !== t.type) continue; + if (t.thresholdUsd != null) { + const cost = event.details?.cost ?? 0; + if (cost < t.thresholdUsd) continue; + } + if (t.pathRe) { + if (!event.path || !t.pathRe.test(event.path)) continue; + } + if (t.matchRe) { + const hay = + `${event.summary ?? ""}\n${event.cmd ?? ""}\n${event.path ?? ""}`; + if (!t.matchRe.test(hay)) continue; + } + // A rule with no match fields is a type-only / threshold-only rule — + // that's fine; it fires on every matching event. + return { + title: expand(t.title, event), + body: expand(t.body, event), + }; + } + return null; +} + +function expand(tmpl: string, e: AgentEvent): string { + return tmpl.replace(/\{\{(\w+)\}\}/g, (_, key) => { + switch (key) { + case "agent": return e.agent; + case "type": return e.type; + case "cmd": return e.cmd ?? ""; + case "path": return e.path ?? ""; + case "tool": return e.tool ?? ""; + case "summary": return e.summary ?? ""; + case "cost": + return e.details?.cost ? `$${e.details.cost.toFixed(4)}` : ""; + default: return ""; + } + }); +} diff --git a/src/util/version.ts b/src/util/version.ts new file mode 100644 index 0000000..83c3f0b --- /dev/null +++ b/src/util/version.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Read package.json once at module load. Two candidate paths cover both +// dev (src/util/version.ts → ../../package.json) and the tsup bundle +// (dist/index.js → ../package.json). +function readVersion(): string { + const here = dirname(fileURLToPath(import.meta.url)); + for (const p of [ + join(here, "..", "..", "package.json"), + join(here, "..", "package.json"), + ]) { + try { + return (JSON.parse(readFileSync(p, "utf8")) as { version: string }).version; + } catch { + // try next candidate + } + } + return "unknown"; +} + +export const VERSION: string = readVersion(); diff --git a/tsup.config.ts b/tsup.config.ts index 045c865..1bf6a4e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ clean: true, dts: false, shims: false, + splitting: false, banner: { js: "#!/usr/bin/env node" }, outDir: "dist", }); diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +dist diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ee14cc0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0iIzU4YTZmZiI+PHBhdGggZD0iTTE2IDRMNCAyNGwxMiAwIDEyLTB6Ii8+PC9zdmc+" /> + <title>agentwatch + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0550594 --- /dev/null +++ b/web/package.json @@ -0,0 +1,11 @@ +{ + "name": "agentwatch-web", + "private": true, + "version": "0.0.4", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..023e93d --- /dev/null +++ b/web/src/components/CommandPalette.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { Search } from "lucide-react"; +import clsx from "clsx"; + +interface Item { + id: string; + title: string; + hint?: string; + to: string; + group: "nav" | "project" | "session"; +} + +const NAV_ITEMS: Item[] = [ + { id: "nav:timeline", title: "Timeline", to: "/", group: "nav" }, + { id: "nav:logs", title: "Logs (disk-backed history)", to: "/logs", group: "nav" }, + { id: "nav:projects", title: "Projects", to: "/projects", group: "nav" }, + { id: "nav:search", title: "Search", to: "/search", group: "nav" }, + { id: "nav:agents", title: "Agents", to: "/agents", group: "nav" }, + { id: "nav:permissions", title: "Permissions", to: "/permissions", group: "nav" }, + { id: "nav:cron", title: "Scheduled", to: "/cron", group: "nav" }, + { id: "nav:trends", title: "Trends", to: "/trends", group: "nav" }, + { id: "nav:budgets", title: "Settings · Budgets", to: "/settings/budgets", group: "nav" }, + { id: "nav:anomaly", title: "Settings · Anomaly", to: "/settings/anomaly", group: "nav" }, + { id: "nav:triggers", title: "Settings · Triggers", to: "/settings/triggers", group: "nav" }, +]; + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [idx, setIdx] = useState(0); + const navigate = useNavigate(); + + const projects = useQuery({ queryKey: ["projects"], queryFn: api.projects, enabled: open }); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + setQ(""); + setIdx(0); + } + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + const projectItems: Item[] = useMemo( + () => + (projects.data?.projects ?? []).map((p) => ({ + id: `proj:${p.name}`, + title: p.name, + hint: `${p.eventCount} events · ${p.sessionIds.length} sessions`, + to: `/projects/${encodeURIComponent(p.name)}`, + group: "project" as const, + })), + [projects.data], + ); + + const all = useMemo(() => [...NAV_ITEMS, ...projectItems], [projectItems]); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + if (!needle) return all.slice(0, 20); + return all + .filter( + (i) => + i.title.toLowerCase().includes(needle) || + (i.hint ?? "").toLowerCase().includes(needle), + ) + .slice(0, 30); + }, [all, q]); + + useEffect(() => setIdx(0), [q]); + + if (!open) return null; + + const choose = (it: Item) => { + setOpen(false); + navigate(it.to); + }; + + return ( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+ + setQ(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setIdx((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const it = filtered[idx]; + if (it) choose(it); + } + }} + placeholder="Jump to view, project, session…" + className="flex-1 bg-transparent outline-none text-sm" + /> + esc +
+
+ {filtered.length === 0 &&
no results
} + {filtered.map((it, i) => ( + + ))} +
+
+
+ ↑↓ navigate + open +
+
+ ⌘K toggle +
+
+
+
+ ); +} diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx new file mode 100644 index 0000000..54454c7 --- /dev/null +++ b/web/src/components/Shell.tsx @@ -0,0 +1,87 @@ +import { NavLink, Outlet } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { Activity, Folder, Terminal, Settings, Search, BarChart3, Shield, Clock, History } from "lucide-react"; +import clsx from "clsx"; +import { useLiveEvents } from "../lib/store"; +import { CommandPalette } from "./CommandPalette"; + +export function Shell() { + useLiveEvents(); // subscribes to SSE on mount; store drives the Timeline + const health = useQuery({ + queryKey: ["health"], + queryFn: api.health, + refetchInterval: 10_000, + }); + return ( +
+
+
+ +
agentwatch
+
+ {health.data?.version ? `v${health.data.version}` : ""} +
+
+ +
+ ⌘K + + {health.isSuccess ? "connected" : "connecting…"} +
+
+
+ +
+ +
+ ); +} + +function NavItem({ + to, + label, + icon, + end, + disabled, +}: { + to: string; + label: string; + icon: React.ReactNode; + end?: boolean; + disabled?: boolean; +}) { + if (disabled) { + return ( + + {icon} + {label} + + ); + } + return ( + + clsx( + "flex items-center gap-1.5 px-3 py-1.5 rounded-md transition", + isActive ? "bg-accent/20 text-accent" : "text-fg-dim hover:bg-bg-elev hover:text-fg", + ) + } + > + {icon} + {label} + + ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..7f7fa9c --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +html, body, #root { + height: 100%; +} + +body { + font-feature-settings: "ss01", "cv11"; +} + +/* Scrollbars */ +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: #2b3139; border-radius: 5px; } +*::-webkit-scrollbar-thumb:hover { background: #484f58; } + +/* Subtle mono for technical content blocks */ +.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } + +/* Row hover */ +.row-hover:hover { background: #12151a; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..ca0fc77 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,239 @@ +import type { AgentEvent, DetectedAgent } from "./types"; + +const API_BASE = (() => { + // In dev (Vite :5173) we proxy /api to :3456 via vite.config.ts. + // In production the UI is served by the same origin, so relative paths work. + return ""; +})(); + +async function getJson(path: string): Promise { + const res = await fetch(`${API_BASE}${path}`); + if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${path}`); + return (await res.json()) as T; +} + +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${path}`); + return (await res.json()) as T; +} + +export const api = { + health: () => getJson<{ ok: boolean; version: string }>("/api/health"), + + events: (params: { + limit?: number; + agent?: string; + session?: string; + project?: string; + type?: string; + q?: string; + before?: string; + } = {}) => { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v != null && v !== "") qs.set(k, String(v)); + } + return getJson<{ events: AgentEvent[]; total: number; returned: number }>( + `/api/events?${qs.toString()}`, + ); + }, + + event: (id: string) => getJson<{ event: AgentEvent }>(`/api/events/${encodeURIComponent(id)}`), + + projects: () => + getJson<{ projects: Array<{ name: string; eventCount: number; cost: number; lastTs?: string; sessionIds: string[] }> }>( + "/api/projects", + ), + + projectSessions: (name: string) => + getJson<{ project: string; sessions: Array }>( + `/api/projects/${encodeURIComponent(name)}/sessions`, + ), + + session: (id: string) => + getJson<{ sessionId: string; agent: string; events: AgentEvent[] }>( + `/api/sessions/${encodeURIComponent(id)}`, + ), + + sessionTokens: (id: string) => + getJson<{ sessionId: string; breakdown: any; turns: any[] }>( + `/api/sessions/${encodeURIComponent(id)}/tokens`, + ), + + sessionCompaction: (id: string) => + getJson<{ sessionId: string; series: any }>( + `/api/sessions/${encodeURIComponent(id)}/compaction`, + ), + + sessionGraph: (id: string) => + getJson<{ sessionId: string; graph: any }>( + `/api/sessions/${encodeURIComponent(id)}/graph`, + ), + + sessionActivity: (id: string) => + getJson<{ + sessionId: string; + buckets: Array<{ category: string; eventCount: number; costUsd: number }>; + }>(`/api/sessions/${encodeURIComponent(id)}/activity`), + + projectActivity: (name: string) => + getJson<{ + project: string; + buckets: Array<{ + category: string; + eventCount: number; + costUsd: number; + sessionsTouched?: number; + }>; + }>(`/api/projects/${encodeURIComponent(name)}/activity`), + + sessionYield: (id: string) => + getJson< + | { + sessionId: string; + ok: true; + project: string; + repoPath: string; + yield: { + sessionId: string; + costUsd: number; + commits: Array<{ + hash: string; + authorDate: string; + authorName: string; + filesChanged: number; + insertions: number; + deletions: number; + subject: string; + }>; + totalInsertions: number; + totalDeletions: number; + totalFilesChanged: number; + costPerCommit: number | null; + costPerLineChanged: number | null; + }; + } + | { sessionId: string; ok: false; reason: string } + >(`/api/sessions/${encodeURIComponent(id)}/yield`), + + projectYield: (name: string) => + getJson< + | { + project: string; + ok: true; + repoPath: string; + yield: { + project: string; + weekly: Array<{ + weekStart: string; + costUsd: number; + commits: number; + costPerCommit: number | null; + }>; + spendWithoutCommit: Array<{ + sessionId: string; + costUsd: number; + commits: never[]; + totalInsertions: number; + totalDeletions: number; + totalFilesChanged: number; + costPerCommit: number | null; + costPerLineChanged: number | null; + }>; + }; + } + | { project: string; ok: false; reason: string } + >(`/api/projects/${encodeURIComponent(name)}/yield`), + + search: ( + query: string, + mode: "live" | "cross" | "semantic" = "live", + limit = 100, + opts: { since?: string; until?: string; agents?: string[] } = {}, + ) => + postJson<{ + mode: string; + hits: Array; + status?: string; + error?: string; + totalScanned?: number; + }>("/api/search", { query, mode, limit, ...opts }), + + agents: () => getJson<{ agents: DetectedAgent[] }>("/api/agents"), + + permissions: () => getJson("/api/permissions"), + + cron: () => getJson<{ jobs: any[]; heartbeats: any[]; scheduledEvents: AgentEvent[] }>("/api/cron"), + + config: (kind: "budgets" | "anomaly" | "triggers") => + getJson<{ kind: string; path: string; value: any; defaults: any }>(`/api/config/${kind}`), + + saveConfig: async (kind: "budgets" | "anomaly" | "triggers", value: unknown) => { + const r = await fetch(`/api/config/${kind}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error ?? r.statusText); + return j; + }, + + trendsCost: (days = 30) => + getJson<{ days: number; data: Array<{ day: string; cost: number; input: number; output: number }> }>( + `/api/trends/cost?days=${days}`, + ), + + trendsCacheHit: (days = 30) => + getJson<{ days: number; data: Array<{ day: string; cacheRead: number; cacheCreate: number; totalInput: number; hitRatio: number }> }>( + `/api/trends/cache-hit?days=${days}`, + ), + + trendsByAgent: (days = 30) => + getJson<{ days: number; agents: string[]; data: Array> }>( + `/api/trends/by-agent?days=${days}`, + ), + + sessionDiffs: (id: string) => + getJson<{ sessionId: string; diffs: Array; count: number }>( + `/api/sessions/${encodeURIComponent(id)}/diffs`, + ), + + replay: (id: string, body: { prompt?: string; binaryPath?: string; timeoutSec?: number }) => + postJson<{ + ok: boolean; + exitCode?: number; + agent: string; + prompt: string; + command: string; + durationMs: number; + stdout: string; + stderr: string; + error?: string; + }>(`/api/sessions/${encodeURIComponent(id)}/replay`, body), +}; + +/** Subscribe to the live event stream. Returns an unsubscribe fn. */ +export function subscribeEvents( + onEvent: (e: AgentEvent) => void, + onHello?: () => void, +): () => void { + const src = new EventSource(`${API_BASE}/api/events/stream`); + if (onHello) { + src.addEventListener("hello", () => onHello()); + } + src.addEventListener("event", (ev: MessageEvent) => { + try { + const e = JSON.parse(ev.data) as AgentEvent; + onEvent(e); + } catch { + // drop malformed frame + } + }); + return () => src.close(); +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..eb40a8b --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,74 @@ +import type { AgentName, EventType } from "./types"; + +export function formatTime(ts: string): string { + return ts.slice(11, 19); // HH:MM:SS +} + +export function formatDateTime(ts: string): string { + return ts.slice(0, 19).replace("T", " "); +} + +/** MM-DD HH:MM:SS — compact but unambiguous for a timeline row. */ +export function formatShortDate(ts: string): string { + return `${ts.slice(5, 10)} ${ts.slice(11, 19)}`; +} + +export function formatUSD(n: number | undefined | null): string { + if (n == null) return "—"; + if (n < 0.01) return `$${n.toFixed(4)}`; + return `$${n.toFixed(2)}`; +} + +export function formatTokens(n: number | undefined | null): string { + if (n == null) return "—"; + if (n < 1000) return String(n); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +} + +const AGENT_COLORS: Record = { + "claude-code": "text-orange-400", + codex: "text-emerald-400", + cursor: "text-fuchsia-400", + gemini: "text-blue-400", + openclaw: "text-cyan-400", + hermes: "text-yellow-400", + aider: "text-pink-400", + cline: "text-lime-400", + windsurf: "text-teal-400", + goose: "text-amber-400", + continue: "text-purple-400", + unknown: "text-gray-400", +}; + +export function agentColor(a: AgentName): string { + return AGENT_COLORS[a] ?? "text-gray-400"; +} + +const TYPE_ICON: Record = { + prompt: "→", + response: "←", + tool_call: "▸", + shell_exec: "$", + file_write: "✎", + file_read: "👁", + file_change: "~", + compaction: "⟳", + session_start: "●", + session_end: "○", +}; + +export function typeIcon(t: EventType): string { + return TYPE_ICON[t] ?? "•"; +} + +const RISK_CLASS: Record = {}; +for (let i = 0; i <= 10; i++) { + if (i >= 8) RISK_CLASS[i] = "bg-danger/20 text-danger"; + else if (i >= 5) RISK_CLASS[i] = "bg-warn/20 text-warn"; + else if (i >= 2) RISK_CLASS[i] = "bg-accent/10 text-accent"; + else RISK_CLASS[i] = "bg-fg/5 text-fg-dim"; +} +export function riskClass(score: number): string { + return RISK_CLASS[Math.max(0, Math.min(10, score))] ?? ""; +} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts new file mode 100644 index 0000000..a5a59eb --- /dev/null +++ b/web/src/lib/store.ts @@ -0,0 +1,59 @@ +import { create } from "zustand"; +import { useEffect } from "react"; +import { api, subscribeEvents } from "./api"; +import type { AgentEvent } from "./types"; + +interface EventStore { + events: AgentEvent[]; // newest-first + initialized: boolean; + setInitial: (events: AgentEvent[]) => void; + push: (event: AgentEvent) => void; +} + +const MAX = 2000; + +export const useEventStore = create((set) => ({ + events: [], + initialized: false, + setInitial: (events) => set({ events, initialized: true }), + push: (event) => + set((state) => { + // Dedupe on id — we can receive an event twice if the initial fetch + // races with the SSE stream. + if (state.events.find((e) => e.id === event.id)) return {}; + const next = [event, ...state.events]; + if (next.length > MAX) next.length = MAX; + return { events: next }; + }), +})); + +/** Hook: on mount fetches initial events + subscribes to SSE stream. + * Safe to call from multiple components — the store is a singleton and + * SSE subscription count is tracked via React effect refcounting. */ +let sseRefCount = 0; +let sseUnsubscribe: (() => void) | null = null; + +export function useLiveEvents(): void { + const { initialized, setInitial, push } = useEventStore(); + + useEffect(() => { + if (!initialized) { + api + .events({ limit: 1000 }) + .then((r) => setInitial(r.events)) + .catch(() => setInitial([])); + } + sseRefCount += 1; + if (!sseUnsubscribe) { + sseUnsubscribe = subscribeEvents((e) => push(e)); + } + return () => { + sseRefCount -= 1; + if (sseRefCount <= 0 && sseUnsubscribe) { + sseUnsubscribe(); + sseUnsubscribe = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..c374c6d --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,83 @@ +// Re-declared to avoid pulling node-flavoured src/schema.ts into the web +// bundle. Must stay in sync with src/schema.ts. + +export type AgentName = + | "claude-code" + | "codex" + | "cursor" + | "gemini" + | "openclaw" + | "hermes" + | "aider" + | "cline" + | "continue" + | "windsurf" + | "goose" + | "unknown"; + +export type EventType = + | "tool_call" + | "file_read" + | "file_write" + | "file_change" + | "shell_exec" + | "prompt" + | "response" + | "compaction" + | "session_start" + | "session_end"; + +export interface AgentEvent { + id: string; + ts: string; + agent: AgentName; + type: EventType; + path?: string; + cmd?: string; + tool?: string; + summary?: string; + sessionId?: string; + riskScore: number; + details?: { + fullText?: string; + thinking?: string; + toolInput?: Record; + toolUseId?: string; + source?: string; + usage?: { input: number; cacheCreate: number; cacheRead: number; output: number }; + cost?: number; + model?: string; + toolResult?: string; + durationMs?: number; + toolError?: boolean; + subAgentId?: string; + category?: string; + }; +} + +export interface ProjectRow { + name: string; + eventCount: number; + cost: number; + lastTs?: string; + sessionIds: string[]; +} + +export interface SessionRow { + sessionId: string; + agent: AgentName; + firstTs?: string; + lastTs?: string; + eventCount: number; + cost: number; +} + +export interface DetectedAgent { + name: AgentName; + label: string; + configPath?: string; + present: boolean; + instrumented?: boolean; + eventCount: number; + lastEventAt: string | null; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bbfdefd --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,84 @@ +import React, { lazy, Suspense } from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Shell } from "./components/Shell"; +import { TimelinePage } from "./routes/Timeline"; +import { ProjectsPage } from "./routes/Projects"; +import { ProjectDetailPage } from "./routes/ProjectDetail"; +import { SessionPage } from "./routes/Session"; +import { EventDetailPage } from "./routes/EventDetail"; +import { SearchPage } from "./routes/Search"; +import { AgentsPage } from "./routes/Agents"; +import { PermissionsPage } from "./routes/Permissions"; +import { CronPage } from "./routes/Cron"; +import { LogsPage } from "./routes/Logs"; +import "./index.css"; + +// Code-split the heavy/rarely-visited pages (recharts + diff viewer + +// d3-hierarchy add most of the bundle weight). Initial route is the +// timeline — users don't pay for the chart libs until they drill in. +const SessionTokensPage = lazy(() => import("./routes/SessionTokens").then((m) => ({ default: m.SessionTokensPage }))); +const SessionCompactionPage = lazy(() => import("./routes/SessionCompaction").then((m) => ({ default: m.SessionCompactionPage }))); +const SessionGraphPage = lazy(() => import("./routes/SessionGraph").then((m) => ({ default: m.SessionGraphPage }))); +const SessionDiffsPage = lazy(() => import("./routes/SessionDiffs").then((m) => ({ default: m.SessionDiffsPage }))); +const SessionReplayPage = lazy(() => import("./routes/SessionReplay").then((m) => ({ default: m.SessionReplayPage }))); +const SessionActivityPage = lazy(() => import("./routes/SessionActivity").then((m) => ({ default: m.SessionActivityPage }))); +const ProjectActivityPage = lazy(() => import("./routes/ProjectActivity").then((m) => ({ default: m.ProjectActivityPage }))); +const SessionYieldPage = lazy(() => import("./routes/SessionYield").then((m) => ({ default: m.SessionYieldPage }))); +const ProjectYieldPage = lazy(() => import("./routes/ProjectYield").then((m) => ({ default: m.ProjectYieldPage }))); +const TrendsPage = lazy(() => import("./routes/Trends").then((m) => ({ default: m.TrendsPage }))); +const SettingsShell = lazy(() => import("./routes/Settings").then((m) => ({ default: m.SettingsShell }))); +const BudgetsSettings = lazy(() => import("./routes/Settings").then((m) => ({ default: m.BudgetsSettings }))); +const AnomalySettings = lazy(() => import("./routes/Settings").then((m) => ({ default: m.AnomalySettings }))); +const TriggersSettings = lazy(() => import("./routes/Settings").then((m) => ({ default: m.TriggersSettings }))); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false, staleTime: 2_000 }, + }, +}); + +function L(node: React.ReactNode) { + return loading…}>{node}; +} + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + }> + } /> + } /> + } /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + } /> + } /> + } /> + } /> + } /> + )} /> + )}> + } /> + )} /> + )} /> + )} /> + + } /> + + + + + , +); diff --git a/web/src/routes/Agents.tsx b/web/src/routes/Agents.tsx new file mode 100644 index 0000000..9cd822b --- /dev/null +++ b/web/src/routes/Agents.tsx @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { agentColor, formatDateTime } from "../lib/format"; +import clsx from "clsx"; +import { Terminal, CheckCircle2, Circle, AlertTriangle } from "lucide-react"; + +export function AgentsPage() { + const q = useQuery({ queryKey: ["agents"], queryFn: api.agents, refetchInterval: 10_000 }); + const agents = q.data?.agents ?? []; + return ( +
+
+ +

agents

+ {agents.filter((a) => a.present).length} installed +
+
+ {agents.map((a) => ( +
+
+ {a.present ? ( + a.instrumented ? ( + + ) : ( + + ) + ) : ( + + )} +
{a.label}
+
+
+ {a.present ? (a.instrumented ? "installed · events captured" : "detected · events TBD") : "not detected"} +
+ {a.configPath && ( +
+ {a.configPath} +
+ )} +
+ events: {a.eventCount} + {a.lastEventAt && last: {formatDateTime(a.lastEventAt)}} +
+
+ ))} +
+
+ ); +} diff --git a/web/src/routes/Cron.tsx b/web/src/routes/Cron.tsx new file mode 100644 index 0000000..a275d11 --- /dev/null +++ b/web/src/routes/Cron.tsx @@ -0,0 +1,98 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { formatDateTime } from "../lib/format"; +import { Clock, Heart, Calendar } from "lucide-react"; + +export function CronPage() { + const q = useQuery({ queryKey: ["cron"], queryFn: api.cron, refetchInterval: 10_000 }); + if (q.isLoading) return
loading…
; + const jobs = q.data?.jobs ?? []; + const heartbeats = q.data?.heartbeats ?? []; + const scheduledEvents = q.data?.scheduledEvents ?? []; + + return ( +
+
+ +

scheduled

+
+ +
+
+ +

cron jobs

+ {jobs.length} +
+ {jobs.length === 0 &&
No OpenClaw cron jobs installed.
} + {jobs.length > 0 && ( + + + + + + + + + + + + {jobs.map((j: any) => ( + + + + + + + + ))} + +
namescheduleagentchannellast run
{j.name ?? j.id}{j.cron ?? j.schedule ?? "—"}{j.agentId ?? "—"}{j.channel ?? "—"}{j.lastRunAt ? formatDateTime(j.lastRunAt) : "never"}
+ )} +
+ +
+
+ +

heartbeats

+ {heartbeats.length} +
+ {heartbeats.length === 0 &&
No heartbeat tasks detected.
} + {heartbeats.length > 0 && ( + + + + + + + + + + + {heartbeats.map((h: any, i: number) => ( + + + + + + + ))} + +
taskagentlastfile
{h.taskName ?? h.name ?? "—"}{h.agentId ?? "—"}{h.lastHeartbeatAt ? formatDateTime(h.lastHeartbeatAt) : "—"}{h.filePath ?? h.file ?? ""}
+ )} +
+ +
+

recent scheduled events {scheduledEvents.length}

+ {scheduledEvents.length === 0 &&
No scheduled events in the live buffer.
} + {scheduledEvents.slice(0, 50).map((e: any) => ( +
+ {formatDateTime(e.ts)} + {e.agent} + {e.type} + {e.summary} +
+ ))} +
+
+ ); +} diff --git a/web/src/routes/EventDetail.tsx b/web/src/routes/EventDetail.tsx new file mode 100644 index 0000000..b33253d --- /dev/null +++ b/web/src/routes/EventDetail.tsx @@ -0,0 +1,120 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { agentColor, formatDateTime, riskClass, typeIcon, formatUSD, formatTokens } from "../lib/format"; +import { ArrowLeft } from "lucide-react"; +import clsx from "clsx"; + +export function EventDetailPage() { + const { id = "" } = useParams(); + const q = useQuery({ queryKey: ["event", id], queryFn: () => api.event(id) }); + + if (q.isLoading) return
loading…
; + if (q.error) return
{String(q.error)}
; + + const e = q.data?.event; + if (!e) return
event not found
; + + return ( +
+
+ + + +

event

+ {e.id} +
+
+
+ + {e.agent}} /> + {typeIcon(e.type)} {e.type}} /> + {e.riskScore}} /> + {e.sessionId && ( + + {e.sessionId.slice(0, 20)} + + } + /> + )} + {e.tool && } + {e.path && {e.path}} />} +
+ + {e.summary && ( + +
{e.summary}
+
+ )} + + {e.cmd && ( + +
{e.cmd}
+
+ )} + + {e.details?.fullText && ( + +
{e.details.fullText}
+
+ )} + + {e.details?.thinking && ( + +
{e.details.thinking}
+
+ )} + + {e.details?.toolInput && ( + +
{JSON.stringify(e.details.toolInput, null, 2)}
+
+ )} + + {e.details?.toolResult && ( + +
{e.details.toolResult}
+
+ )} + + {e.details?.usage && ( + +
+ + + + + {e.details.cost != null && } + {e.details.model && {e.details.model}} />} +
+
+ )} + + {e.details?.source && ( +
source: {e.details.source}
+ )} +
+
+ ); +} + +function Info({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Block({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/web/src/routes/Logs.tsx b/web/src/routes/Logs.tsx new file mode 100644 index 0000000..53a27e8 --- /dev/null +++ b/web/src/routes/Logs.tsx @@ -0,0 +1,223 @@ +import { useState, useMemo, useRef, useEffect } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { api } from "../lib/api"; +import { agentColor, formatShortDate } from "../lib/format"; +import { Search, History, Filter } from "lucide-react"; +import clsx from "clsx"; + +// Relative-time presets that map to ISO `since` on the server. +const PRESETS: Array<{ id: string; label: string; hours: number | null }> = [ + { id: "1h", label: "1h", hours: 1 }, + { id: "24h", label: "24h", hours: 24 }, + { id: "7d", label: "7d", hours: 24 * 7 }, + { id: "30d", label: "30d", hours: 24 * 30 }, + { id: "all", label: "all", hours: null }, +]; + +const AGENT_OPTIONS = ["claude-code", "codex", "gemini"] as const; + +export function LogsPage() { + const [q, setQ] = useState(""); + const [preset, setPreset] = useState("7d"); + const [agents, setAgents] = useState>(new Set()); + const [limit, setLimit] = useState(100); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const detected = useQuery({ queryKey: ["agents"], queryFn: api.agents }); + + // Which agent adapters actually cover cross-search on disk. + // (cross-search.ts today reads claude/codex/gemini JSONLs; openclaw and + // hermes persist differently so they're surfaced separately.) + const crossAgents = useMemo(() => new Set(AGENT_OPTIONS), []); + + const mut = useMutation({ + mutationFn: ({ q, since, agents, limit }: { q: string; since?: string; agents?: string[]; limit: number }) => + api.search(q, "cross", limit, { since, agents }), + }); + + const onRun = (e?: React.FormEvent) => { + e?.preventDefault(); + const needle = q.trim(); + if (!needle) return; + const h = PRESETS.find((p) => p.id === preset)?.hours ?? null; + const since = h == null ? undefined : new Date(Date.now() - h * 3_600_000).toISOString(); + const agentList = agents.size > 0 ? Array.from(agents) : undefined; + mut.mutate({ q: needle, since, agents: agentList, limit }); + }; + + return ( +
+
+
+ +

Logs

+ disk-backed history search +
+
+ + setQ(e.target.value)} + placeholder="search every session file on disk — press enter" + className="w-full bg-bg-elev border border-bg-border rounded-md pl-9 pr-3 py-1.5 text-sm outline-none focus:border-accent" + /> +
+
+ when: + {PRESETS.map((p) => ( + + ))} +
+ +
+ +
+ agents: + {AGENT_OPTIONS.map((a) => { + const active = agents.has(a); + const installed = detected.data?.agents.find((x) => x.name === a && x.present); + return ( + + ); + })} + results cap: + + {detected.data?.agents.some((a) => a.name === "openclaw" && a.present) && ( + + OpenClaw + Hermes not scanned yet (SQLite storage — adapter TBD). + + )} +
+ +
+ {mut.isPending &&
searching disk…
} + {mut.isError && ( +
{String((mut.error as Error).message)}
+ )} + {mut.data && mut.data.hits.length === 0 && ( +
+
No matches on disk for “{q.trim()}”.
+
+ Try a wider time window, drop agent filters, or switch query to something broader. +
+
+ )} + {mut.data && mut.data.hits.length > 0 && ( + <> +
+ + {mut.data.hits.length} hits + {mut.data.totalScanned != null && mut.data.totalScanned !== mut.data.hits.length && ( + · {mut.data.totalScanned} scanned before filters + )} + · window: {PRESETS.find((p) => p.id === preset)?.label} + {agents.size > 0 && · agents: {Array.from(agents).join(", ")}} +
+ + + + + + + + + + + {mut.data.hits.map((h: any, i: number) => { + const hit = h.hit ?? h; + const ts = hit.ts; + return ( + + + + + + + ); + })} + +
date / timeagentsessionmatch
+ {ts ? formatShortDate(ts) : "—"} + + {hit.agent} + + {hit.sessionId ? ( + + {hit.sessionId.slice(0, 16)} + + ) : ( + + )} + + {hit.line} +
+ + )} + {!mut.data && !mut.isPending && ( +
+
Type a query + enter.
+
+ This scans the session JSONLs on disk (`~/.claude/projects/`, `~/.codex/sessions/`, `~/.gemini/tmp/`). Unlike the + Timeline page, results go all the way back, not just since agentwatch booted. +
+
+ )} +
+
+ ); +} diff --git a/web/src/routes/Permissions.tsx b/web/src/routes/Permissions.tsx new file mode 100644 index 0000000..f3d7be9 --- /dev/null +++ b/web/src/routes/Permissions.tsx @@ -0,0 +1,50 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { Shield, ShieldCheck, ShieldAlert } from "lucide-react"; + +export function PermissionsPage() { + const q = useQuery({ queryKey: ["permissions"], queryFn: api.permissions }); + + if (q.isLoading) return
loading…
; + const p = q.data ?? {}; + + return ( +
+
+ +

permissions

+
+
+ + + + +
+
+ ); +} + +function AgentPermissionCard({ label, perms }: { label: string; perms: any }) { + if (!perms) { + return ( +
+
+ +

{label}

+ not installed / no config +
+
+ ); + } + return ( +
+
+ +

{label}

+
+
+        {JSON.stringify(perms, null, 2)}
+      
+
+ ); +} diff --git a/web/src/routes/ProjectActivity.tsx b/web/src/routes/ProjectActivity.tsx new file mode 100644 index 0000000..f52b4ae --- /dev/null +++ b/web/src/routes/ProjectActivity.tsx @@ -0,0 +1,217 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatUSD } from "../lib/format"; +import { + PieChart, + Pie, + Cell, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { ArrowLeft } from "lucide-react"; + +const CATEGORY_COLORS: Record = { + coding: "#3fb950", + debugging: "#f85149", + exploration: "#58a6ff", + planning: "#bc8cff", + refactor: "#d29922", + testing: "#39d353", + docs: "#8b949e", + chat: "#7d8590", + config: "#e3b341", + review: "#a371f7", + devops: "#ff7b72", + research: "#79c0ff", +}; + +function colorFor(category: string): string { + return CATEGORY_COLORS[category] ?? "#7d8590"; +} + +export function ProjectActivityPage() { + const { name = "" } = useParams(); + const decoded = decodeURIComponent(name); + + const q = useQuery({ + queryKey: ["project-activity", name], + queryFn: () => api.projectActivity(name), + refetchInterval: 5_000, + }); + + const buckets = q.data?.buckets ?? []; + const totalEvents = buckets.reduce((s, b) => s + b.eventCount, 0); + const totalCost = buckets.reduce((s, b) => s + b.costUsd, 0); + + return ( +
+
+ + + +

activity

+ {decoded} +
+ + {q.isLoading &&
loading…
} + + {!q.isLoading && totalEvents === 0 && ( +
+ no classified events yet for this project. +
+ )} + + {!q.isLoading && totalEvents > 0 && ( + <> +
+ + + + s + (b.sessionsTouched ?? 0), + 0, + ), + )} + /> +
+ +
+
+
+ events by category +
+
+ + + + `${p.name ?? ""} ${p.percent != null ? `${(p.percent * 100).toFixed(0)}%` : ""}` + } + > + {buckets.map((b) => ( + + ))} + + + + + +
+
+ +
+
+ cost by category +
+
+ + + b.costUsd > 0)} + dataKey="costUsd" + nameKey="category" + cx="50%" + cy="50%" + outerRadius={90} + label={(p: { name?: string; percent?: number }) => + `${p.name ?? ""} ${p.percent != null ? `${(p.percent * 100).toFixed(0)}%` : ""}` + } + > + {buckets + .filter((b) => b.costUsd > 0) + .map((b) => ( + + ))} + + + + + +
+
+
+ +
+
+ per-category breakdown +
+ + + + + + + + + + + + + {buckets.map((b) => ( + + + + + + + + + ))} + +
categoryeventscostsessions touched% events% cost
+ + {b.category} + {b.eventCount}{formatUSD(b.costUsd)}{b.sessionsTouched ?? "—"} + {`${((b.eventCount / totalEvents) * 100).toFixed(1)}%`} + + {totalCost > 0 + ? `${((b.costUsd / totalCost) * 100).toFixed(1)}%` + : "—"} +
+
+ + )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/ProjectDetail.tsx b/web/src/routes/ProjectDetail.tsx new file mode 100644 index 0000000..36d4111 --- /dev/null +++ b/web/src/routes/ProjectDetail.tsx @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { agentColor, formatDateTime, formatUSD } from "../lib/format"; +import { ArrowLeft, PieChart, DollarSign } from "lucide-react"; +import clsx from "clsx"; + +export function ProjectDetailPage() { + const { name = "" } = useParams(); + const q = useQuery({ + queryKey: ["project-sessions", name], + queryFn: () => api.projectSessions(name), + refetchInterval: 5_000, + }); + + const sessions = q.data?.sessions ?? []; + + return ( +
+
+ + + +

{decodeURIComponent(name)}

+ {sessions.length} sessions + + activity + + + yield + +
+ + + + + + + + + + + + + {sessions.map((s: any) => ( + + + + + + + + + ))} + +
agentsessioneventscostfirstlast
{s.agent} + + {s.sessionId} + + {s.eventCount}{formatUSD(s.cost)}{s.firstTs ? formatDateTime(s.firstTs) : "—"}{s.lastTs ? formatDateTime(s.lastTs) : "—"}
+
+ ); +} diff --git a/web/src/routes/ProjectYield.tsx b/web/src/routes/ProjectYield.tsx new file mode 100644 index 0000000..734a520 --- /dev/null +++ b/web/src/routes/ProjectYield.tsx @@ -0,0 +1,289 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatUSD } from "../lib/format"; +import { + ComposedChart, + Line, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { ArrowLeft } from "lucide-react"; + +type SortKey = "cost" | "lines" | "files"; + +export function ProjectYieldPage() { + const { name = "" } = useParams(); + const decoded = decodeURIComponent(name); + + const q = useQuery({ + queryKey: ["project-yield", name], + queryFn: () => api.projectYield(name), + refetchInterval: 10_000, + }); + + return ( +
+
+ + + +

yield

+ {decoded} +
+ + {q.isLoading &&
loading…
} + + {!q.isLoading && q.data && q.data.ok === false && ( +
+
+ no yield data: {q.data.reason} +
+
+ yield correlates project sessions with the commits landed during their + windows. Requires the project to be a git repo under{" "} + WORKSPACE_ROOT. +
+
+ )} + + {!q.isLoading && q.data && q.data.ok === true && ( + + )} +
+ ); +} + +function ProjectYieldBody({ + data, +}: { + data: Extract>, { ok: true }>; +}) { + const [sortKey, setSortKey] = useState("cost"); + const y = data.yield; + + const totals = useMemo(() => { + let cost = 0; + let commits = 0; + for (const w of y.weekly) { + cost += w.costUsd; + commits += w.commits; + } + const overallPerCommit = commits > 0 ? cost / commits : null; + return { cost, commits, overallPerCommit }; + }, [y.weekly]); + + const sortedSpend = useMemo(() => { + const copy = [...y.spendWithoutCommit]; + if (sortKey === "cost") { + copy.sort((a, b) => b.costUsd - a.costUsd); + } else if (sortKey === "lines") { + copy.sort( + (a, b) => + b.totalInsertions + b.totalDeletions - (a.totalInsertions + a.totalDeletions), + ); + } else { + copy.sort((a, b) => b.totalFilesChanged - a.totalFilesChanged); + } + return copy; + }, [y.spendWithoutCommit, sortKey]); + + const empty = y.weekly.length === 0 && y.spendWithoutCommit.length === 0; + + return ( + <> +
+ + + + +
+ +
+ repo {data.repoPath} +
+ + {empty ? ( +
+ no sessions or commits in window for this project yet. +
+ ) : ( + <> +
+
+ weekly spend vs commits ($/commit overlay) +
+
+ + + + t.slice(0, 10)} + /> + `$${v.toFixed(0)}`} + /> + + { + const val = typeof v === "number" ? v : Number(v ?? 0); + if (name === "costUsd") return [formatUSD(val), "cost"]; + if (name === "costPerCommit") + return [val ? formatUSD(val) : "—", "$/commit"]; + return [String(val), String(name ?? "")]; + }} + /> + + + + + + +
+
+ +
+
+
+ spend without commit +
+ + {sortedSpend.length} session{sortedSpend.length === 1 ? "" : "s"} + +
+ sort: + + cost + + + lines + + + files + +
+
+ {sortedSpend.length === 0 ? ( +
+ every session in this project landed at least one commit. ✨ +
+ ) : ( + + + + + + + + + + + + {sortedSpend.map((s) => ( + + + + + + + + ))} + +
sessioncostfiles+
+ + {s.sessionId.slice(0, 24)} + + {formatUSD(s.costUsd)}{s.totalFilesChanged} + +{s.totalInsertions} + + −{s.totalDeletions} +
+ )} +
+ + )} + + ); +} + +function SortBtn({ + k, + cur, + onClick, + children, +}: { + k: SortKey; + cur: SortKey; + onClick: (k: SortKey) => void; + children: React.ReactNode; +}) { + const active = k === cur; + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/Projects.tsx b/web/src/routes/Projects.tsx new file mode 100644 index 0000000..af56ddc --- /dev/null +++ b/web/src/routes/Projects.tsx @@ -0,0 +1,67 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatUSD, formatDateTime } from "../lib/format"; +import { Folder, ArrowRight } from "lucide-react"; + +export function ProjectsPage() { + const q = useQuery({ + queryKey: ["projects"], + queryFn: api.projects, + refetchInterval: 5_000, + }); + + if (q.isLoading) return
loading…
; + if (q.error) return
failed: {String(q.error)}
; + + const projects = q.data?.projects ?? []; + + return ( +
+
+

Projects

+ {projects.length} +
+ {projects.length === 0 ? ( +
+ No projects detected yet — once an agent writes to a project, it'll appear here. +
+ ) : ( +
+ {projects.map((p) => ( + +
+
+ +
{p.name}
+
+ +
+
+ + + +
+ {p.lastTs && ( +
last: {formatDateTime(p.lastTs)}
+ )} + + ))} +
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/Search.tsx b/web/src/routes/Search.tsx new file mode 100644 index 0000000..ec8314a --- /dev/null +++ b/web/src/routes/Search.tsx @@ -0,0 +1,144 @@ +import { useState, useRef, useEffect } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { Link } from "react-router-dom"; +import { agentColor, formatTime, typeIcon } from "../lib/format"; +import { Search as SearchIcon } from "lucide-react"; +import clsx from "clsx"; + +type Mode = "live" | "cross" | "semantic"; + +export function SearchPage() { + const [query, setQuery] = useState(""); + const [mode, setMode] = useState("live"); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const search = useMutation({ + mutationFn: ({ q, m }: { q: string; m: Mode }) => api.search(q, m, 100), + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const q = query.trim(); + if (!q) return; + search.mutate({ q, m: mode }); + }; + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="search — enter to run" + className="w-full bg-bg-elev border border-bg-border rounded-md pl-9 pr-3 py-2 outline-none focus:border-accent" + /> +
+
+ {(["live", "cross", "semantic"] as Mode[]).map((m) => ( + + ))} +
+ +
+ +
+ {search.data?.status && ( +
{search.data.status}
+ )} + {search.data?.error && ( +
{search.data.error}
+ )} + {search.data && search.data.hits.length === 0 && !search.isPending && ( +
no matches.
+ )} + {search.data && search.data.hits.length > 0 && } + {!search.data && !search.isPending && ( +
+
Three modes:
+
    +
  • live — substring across the current in-memory buffer (fast, recent only)
  • +
  • cross — ripgrep across every JSONL on disk (slow first time, thorough)
  • +
  • semantic — hybrid BM25 + embedding search (fuzzy, needs the index built in the TUI first)
  • +
+
+ )} +
+
+ ); +} + +function ModeHint({ mode }: { mode: Mode }) { + const hints: Record = { + live: "searching the in-memory ring buffer (500–2000 events).", + cross: "searching every session JSONL on disk.", + semantic: "searching the hybrid BM25 + embedding index.", + }; + return
{hints[mode]}
; +} + +function Hits({ hits }: { hits: any[]; mode: Mode }) { + return ( +
+ {hits.map((h, i) => { + if (h.kind === "live") { + const e = h.event; + return ( + +
+ {formatTime(e.ts)} + {e.agent} + + {typeIcon(e.type)} {e.type} + +
+
{e.summary ?? e.cmd ?? e.path ?? e.tool ?? ""}
+ + ); + } + const hit = h.hit; + return ( + +
+ {hit.agent ?? "—"} + {hit.sessionId?.slice(0, 20)} + {hit.score != null && score {hit.score.toFixed(3)}} +
+
{hit.snippet ?? ""}
+ + ); + })} +
+ ); +} diff --git a/web/src/routes/Session.tsx b/web/src/routes/Session.tsx new file mode 100644 index 0000000..56000ad --- /dev/null +++ b/web/src/routes/Session.tsx @@ -0,0 +1,109 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { agentColor, formatTime, riskClass, typeIcon } from "../lib/format"; +import { ArrowLeft, Download, BarChart3, Activity, GitBranch, FileEdit, Play, PieChart, DollarSign } from "lucide-react"; +import clsx from "clsx"; +import type { AgentEvent } from "../lib/types"; + +export function SessionPage() { + const { id = "" } = useParams(); + const q = useQuery({ + queryKey: ["session", id], + queryFn: () => api.session(id), + refetchInterval: 2_000, + }); + + if (q.isLoading) return
loading…
; + if (q.error) return
{String(q.error)}
; + + const events: AgentEvent[] = q.data?.events ?? []; + // Server now returns session events oldest-first directly — chronological + // order is what we want for a session playback view. + const ordered = events; + + return ( +
+
+ + + +

session:{id.slice(0, 16)}

+ {q.data?.agent} + {events.length} events +
+ + tokens + + + compaction + + + graph + + + diffs + + + replay + + + activity + + + yield + + + .md + + + .json + +
+
+
+ {ordered.map((e) => ( + + ))} +
+
+ ); +} + +function SessionEventRow({ event }: { event: AgentEvent }) { + const body = event.details?.fullText ?? event.details?.toolResult ?? event.details?.thinking; + return ( +
+
+ {formatTime(event.ts)} + {event.agent} + + {typeIcon(event.type)} {event.type} + + + {event.riskScore} + + + detail → + +
+ {event.summary &&
{event.summary}
} + {body && ( +
+          {body.slice(0, 2000)}
+          {body.length > 2000 && `… (${body.length - 2000} more chars)`}
+        
+ )} +
+ ); +} diff --git a/web/src/routes/SessionActivity.tsx b/web/src/routes/SessionActivity.tsx new file mode 100644 index 0000000..480ddff --- /dev/null +++ b/web/src/routes/SessionActivity.tsx @@ -0,0 +1,201 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatUSD } from "../lib/format"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { ArrowLeft } from "lucide-react"; +import type { AgentEvent } from "../lib/types"; + +const CATEGORY_COLORS: Record = { + coding: "#3fb950", + debugging: "#f85149", + exploration: "#58a6ff", + planning: "#bc8cff", + refactor: "#d29922", + testing: "#39d353", + docs: "#8b949e", + chat: "#7d8590", + config: "#e3b341", + review: "#a371f7", + devops: "#ff7b72", + research: "#79c0ff", +}; + +function colorFor(category: string): string { + return CATEGORY_COLORS[category] ?? "#7d8590"; +} + +/** Turn an ISO timestamp into a UTC minute-bucket key (YYYY-MM-DD HH:MM). */ +function bucketKey(ts: string): string { + return ts.slice(0, 16).replace("T", " "); +} + +export function SessionActivityPage() { + const { id = "" } = useParams(); + + const sessionQ = useQuery({ + queryKey: ["session", id], + queryFn: () => api.session(id), + refetchInterval: 5_000, + }); + const activityQ = useQuery({ + queryKey: ["session-activity", id], + queryFn: () => api.sessionActivity(id), + refetchInterval: 5_000, + }); + + const { chartData, categories } = useMemo(() => { + const events: AgentEvent[] = sessionQ.data?.events ?? []; + if (events.length === 0) return { chartData: [], categories: [] as string[] }; + const cats = new Set(); + const byBucket = new Map>(); + for (const e of events) { + const cat = (e.details?.category as string | undefined) ?? "chat"; + cats.add(cat); + const k = bucketKey(e.ts); + let row = byBucket.get(k); + if (!row) { + row = { _bucket: 0 } as unknown as Record; + byBucket.set(k, row); + } + row[cat] = (row[cat] ?? 0) + 1; + } + const ordered = Array.from(byBucket.entries()) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => ({ bucket: k, ...v })); + return { chartData: ordered, categories: Array.from(cats).sort() }; + }, [sessionQ.data]); + + const buckets = activityQ.data?.buckets ?? []; + const totalCost = buckets.reduce((s, b) => s + b.costUsd, 0); + const totalEvents = buckets.reduce((s, b) => s + b.eventCount, 0); + const isLoading = sessionQ.isLoading || activityQ.isLoading; + + return ( +
+
+ + + +

activity

+ {id.slice(0, 16)} +
+ + {isLoading &&
loading…
} + + {!isLoading && totalEvents === 0 && ( +
+ no classified events yet for this session. +
+ )} + + {!isLoading && totalEvents > 0 && ( + <> +
+ + + + 0 + ? `${chartData[0]!.bucket.slice(11)} – ${chartData[chartData.length - 1]!.bucket.slice(11)}` + : "—" + } + /> +
+ +
+
+ events × category over time (1-min buckets) +
+
+ + + + + + + + {categories.map((c) => ( + + ))} + + +
+
+ +
+
+ per-category summary +
+ + + + + + + + + + + + {buckets.map((b) => ( + + + + + + + + ))} + +
categoryeventscost% events% cost
+ + {b.category} + {b.eventCount}{formatUSD(b.costUsd)} + {totalEvents > 0 + ? `${((b.eventCount / totalEvents) * 100).toFixed(1)}%` + : "—"} + + {totalCost > 0 + ? `${((b.costUsd / totalCost) * 100).toFixed(1)}%` + : "—"} +
+
+ + )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/SessionCompaction.tsx b/web/src/routes/SessionCompaction.tsx new file mode 100644 index 0000000..2ea1187 --- /dev/null +++ b/web/src/routes/SessionCompaction.tsx @@ -0,0 +1,83 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatTime } from "../lib/format"; +import { ArrowLeft } from "lucide-react"; +import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ReferenceLine, ResponsiveContainer } from "recharts"; + +export function SessionCompactionPage() { + const { id = "" } = useParams(); + const q = useQuery({ + queryKey: ["session-compaction", id], + queryFn: () => api.sessionCompaction(id), + refetchInterval: 3_000, + }); + + if (q.isLoading) return
loading…
; + const s = q.data?.series; + if (!s) return
no series data
; + + const chartData = s.points.map((p: any, i: number) => ({ + i, + time: formatTime(p.ts), + kind: p.kind, + fill: Math.round(p.fillBefore * 100), + fillAfter: p.fillAfter != null ? Math.round(p.fillAfter * 100) : null, + label: p.label, + })); + + const compactionIndexes = chartData.filter((d: any) => d.kind === "compaction"); + + return ( +
+
+ + + +

context / compaction

+ {id.slice(0, 16)} +
+
+ + + + p.kind === "turn").length)} /> +
+ +
+
context fill % over time
+
+ + + + + + + + {compactionIndexes.map((c: any) => ( + + ))} + + + +
+ {s.compactionCount > 0 && ( +
+ dashed red lines = compaction points (context reset by the agent). +
+ )} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/SessionDiffs.tsx b/web/src/routes/SessionDiffs.tsx new file mode 100644 index 0000000..da4ec50 --- /dev/null +++ b/web/src/routes/SessionDiffs.tsx @@ -0,0 +1,107 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatTime, agentColor } from "../lib/format"; +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; +import { ArrowLeft, FileEdit, MessageSquare } from "lucide-react"; +import clsx from "clsx"; + +export function SessionDiffsPage() { + const { id = "" } = useParams(); + const q = useQuery({ + queryKey: ["session-diffs", id], + queryFn: () => api.sessionDiffs(id), + refetchInterval: 5_000, + }); + const diffs: Array = q.data?.diffs ?? []; + + return ( +
+
+ + + +

diff attribution

+ {id.slice(0, 16)} + {diffs.length} writes +
+ {q.isLoading &&
loading…
} + {!q.isLoading && diffs.length === 0 && ( +
No file writes or edits in this session.
+ )} +
+ {diffs.map((d, i) => ( + + ))} +
+
+ ); +} + +function DiffCard({ entry }: { entry: any }) { + const ev = entry.event; + const prompt = entry.triggeringPrompt; + const hasEdit = entry.oldString != null && entry.newString != null; + const hasNewContent = !hasEdit && entry.content != null; + + return ( +
+
+ + {ev.agent} + {formatTime(ev.ts)} + {ev.type} + {ev.path && {ev.path}} +
+ {prompt && ( +
+
+ triggering prompt +
+
+ {prompt.details?.fullText ?? prompt.summary ?? "—"} +
+ + → event detail + +
+ )} +
+ {hasEdit && ( +
+ +
+ )} + {hasNewContent && ( +
{entry.content}
+ )} + {!hasEdit && !hasNewContent && ( +
+ No inline content (tool input didn't include old_string/new_string or content). +
+ )} +
+
+ ); +} diff --git a/web/src/routes/SessionGraph.tsx b/web/src/routes/SessionGraph.tsx new file mode 100644 index 0000000..12babf1 --- /dev/null +++ b/web/src/routes/SessionGraph.tsx @@ -0,0 +1,123 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { agentColor, formatUSD } from "../lib/format"; +import { hierarchy, tree, type HierarchyPointNode } from "d3-hierarchy"; +import { useMemo } from "react"; +import clsx from "clsx"; +import { ArrowLeft } from "lucide-react"; + +interface Node { + kind: "session" | "call"; + agent?: string; + callee?: string; + sessionId?: string; + prompt?: string; + eventId: string; + cost: number; + inputTokens: number; + outputTokens: number; + events: number; + children: Node[]; +} + +export function SessionGraphPage() { + const { id = "" } = useParams(); + const q = useQuery({ + queryKey: ["session-graph", id], + queryFn: () => api.sessionGraph(id), + refetchInterval: 5_000, + }); + + const root = q.data?.graph as Node | null | undefined; + + const layout = useMemo(() => { + if (!root) return null; + const h = hierarchy(root, (n) => n.children); + const nodeCount = h.descendants().length; + const width = Math.max(900, nodeCount * 30); + const height = Math.max(400, h.height * 120 + 120); + const layouter = tree().size([width, height - 80]); + const positioned = layouter(h); + return { positioned, width, height }; + }, [root]); + + return ( +
+
+ + + +

call graph

+ {id.slice(0, 16)} +
+
+ {q.isLoading &&
loading…
} + {!q.isLoading && !root && ( +
+ No call graph — this session didn't spawn any sub-agents or shell-out to other CLIs. +
+ )} + {root && layout && ( +
+ + {/* Edges */} + {layout.positioned.links().map((link, i) => ( + + ))} + {/* Nodes */} + {layout.positioned.descendants().map((n) => ( + + ))} + +
+ )} +
+
+ ); +} + +function linkPath(s: HierarchyPointNode, t: HierarchyPointNode): string { + // Smooth S-curve from source to target. + const midY = (s.y + t.y) / 2; + return `M${s.x},${s.y} C${s.x},${midY} ${t.x},${midY} ${t.x},${t.y}`; +} + +function GraphNode({ node }: { node: HierarchyPointNode }) { + const d = node.data; + const label = d.kind === "session" + ? d.agent ?? "session" + : `→ ${d.callee ?? "call"}`; + const linkTo = d.sessionId ? `/sessions/${encodeURIComponent(d.sessionId)}` : null; + return ( + + + + {label} + + + {d.events} ev · {formatUSD(d.cost)} + + {linkTo && ( + + + + )} + + ); +} diff --git a/web/src/routes/SessionReplay.tsx b/web/src/routes/SessionReplay.tsx new file mode 100644 index 0000000..c06e921 --- /dev/null +++ b/web/src/routes/SessionReplay.tsx @@ -0,0 +1,135 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { api } from "../lib/api"; +import { agentColor } from "../lib/format"; +import { ArrowLeft, Play, AlertCircle } from "lucide-react"; +import clsx from "clsx"; + +const REPLAY_SUPPORTED = new Set(["claude-code", "codex", "gemini", "hermes"]); + +export function SessionReplayPage() { + const { id = "" } = useParams(); + const q = useQuery({ queryKey: ["session", id], queryFn: () => api.session(id) }); + const [prompt, setPrompt] = useState(""); + const [timeoutSec, setTimeoutSec] = useState(60); + + const agent = q.data?.agent ?? "unknown"; + const supported = REPLAY_SUPPORTED.has(agent); + + useEffect(() => { + if (!q.data) return; + // Events arrive oldest-first; first prompt = first match. + const firstPromptEv = q.data.events.find((e: any) => e.type === "prompt"); + const original = firstPromptEv?.details?.fullText ?? firstPromptEv?.summary ?? ""; + if (original && !prompt) setPrompt(original); + }, [q.data]); + + const mut = useMutation({ + mutationFn: () => api.replay(id, { prompt, timeoutSec }), + }); + + const run = mut.data; + + return ( +
+
+ + + +

replay

+ {agent} + {id.slice(0, 16)} +
+ + {!supported && ( +
+ +
+
Replay not supported for {agent}.
+
+ Currently wired: claude-code, codex, gemini, hermes (single-turn exec mode). You can still edit the prompt and copy the command below. +
+
+
+ )} + +
+
+ +