diff --git a/CLAUDE.md b/CLAUDE.md index a5d486ae..beb60a43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x ## Commands Always use pnpm (not npm/yarn) for this project. Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel. +When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`). - `pnpm install` - Install dependencies - `pnpm dev` - Dev server with hot reload diff --git a/README.md b/README.md index 788f3ac4..4c3bb632 100644 --- a/README.md +++ b/README.md @@ -14,52 +14,87 @@

- 100% free, open source. No API keys. No configuration. + 100% free, open source. No API keys. No configuration. Runs entirely locally.


+## Table of Contents + +- [What is this](#what-is-this) +- [Quick start](#quick-start) +- [Installation](#installation) +- [FAQ](#faq) +- [Development](#development) +- [Roadmap](#roadmap) +- [Links](#links) +- [Contributing](#contributing) +- [Security](#security) +- [License](#license) + +--- + ## What is this -A new approach to task management with AI agents. +**Claude Agent Teams UI** is a desktop app that turns Claude Code's "Orchestrate Teams" feature into a full task management experience. Create agent teams, watch them work on a kanban board, review their code changes, and stay in control — all running locally on your machine. -- **Assemble your team** — create agent teams with different roles that work autonomously in parallel -- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments -- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own -- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment -- **Full tool visibility** — inspect exactly which tools an agent used to complete each task -- **Live process section** — see which agents are running processes and open URLs directly in the browser -- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work +### Who is this for + +- **Developers** who want AI agents to handle tasks in parallel while they oversee progress +- **Teams** using Claude Code and needing a shared task board, code review workflow, and team messaging +- **Anyone** who wants to browse and analyze Claude Code session history without running agents + +### How it works + +1. **Create a team** — Define roles (e.g. lead, frontend, backend) and a provisioning prompt. The app spawns Claude Code sessions as autonomous team members. +2. **Tasks flow automatically** — Agents create tasks, assign each other, move cards, leave comments, and send messages. You see everything on the kanban board in real time. +3. **Review like in Cursor** — When a task is done, you see the diff, approve, reject, or request changes. Agents get notified and can fix issues. +4. **Stay in control** — Send a direct message to any agent, add a comment on a task, or use quick actions on cards whenever you need to clarify or add work. + +### Key features + +| Feature | Description | +|---------|-------------| +| **Kanban board** | Tasks move through columns as agents work. Drag-and-drop, filters, quick actions. | +| **Code review** | Full diff view per task. Approve, reject, or request changes. Agents get notified. | +| **Team messaging** | Agents send direct messages. Inbox, notifications, clarification flags. | +| **Tool visibility** | See exactly which tools agents used (Bash, Read, Edit, etc.) for each task. | +| **Live processes** | See which agents are running. Open URLs directly in the browser. | +| **Session analysis** | Deep breakdown of each Claude session: commands, reasoning, subprocesses. |
More features
-- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place -- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses -- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states -- **Solo mode** — a one-member team: a single agent that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time -- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box -- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app -- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree -- **Team member stats** — global performance statistics for every member of the team -- **Attach code context** — reference files or code snippets in your messages, just like in Cursor -- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur +- **Recent tasks across projects** — Browse the latest completed tasks from all your projects in one place +- **Solo mode** — One-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime +- **Advanced context monitoring** — Token breakdown: user messages, CLAUDE.md, tool outputs, thinking text, team coordination. See usage, context window %, and cost per category +- **Smart task-to-log matching** — Links Claude session logs to tasks by status change timestamps, even when tasks move back and forth +- **Zero-setup onboarding** — Built-in Claude Code installation and authentication +- **Built-in code editor** — Edit project files with Git support without leaving the app +- **Branch strategy** — Choose via prompt: single branch or git worktree per agent +- **Team member stats** — Global performance statistics per member +- **Attach code context** — Reference files or snippets in messages, like in Cursor +- **Notification system** — Configurable alerts when tasks complete, agents need attention, or errors occur +- **MCP integration** — Built-in [mcp-server](./mcp-server) for Cursor, Claude Desktop, and other MCP clients. 13 tools for task CRUD, kanban, reviews, and messaging — see [mcp-server/README.md](./mcp-server/README.md)
---- +### Tech stack - ## Installation @@ -101,6 +136,8 @@ No prerequisites — Claude Code can be installed and configured directly from t +**System requirements:** macOS 10.15+, Windows 10+, or Linux (glibc 2.28+). Node.js is not required for the desktop app. + --- ## FAQ @@ -114,19 +151,19 @@ No. The app includes built-in installation and authentication — just launch an
Does it read or upload my code?
-No. Everything runs locally on your machine. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere. +No. Everything runs locally. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere.
Can agents communicate with each other?
-Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol. +Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol.
Is it free?
-Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents. +Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
@@ -138,13 +175,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
What happens if an agent gets stuck?
-You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it. +Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
Can I use it just to view past sessions without running agents?
-Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history. +Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
@@ -171,9 +208,9 @@ pnpm install pnpm dev ``` -The app auto-discovers your Claude Code projects from `~/.claude/`. +The app auto-discovers Claude Code projects from `~/.claude/`. -#### Build for Distribution +### Build for distribution ```bash pnpm dist:mac:arm64 # macOS Apple Silicon (.dmg) @@ -183,32 +220,48 @@ pnpm dist:linux # Linux (AppImage/.deb/.rpm/.pacman) pnpm dist # macOS + Windows + Linux ``` -#### Scripts +### Scripts | Command | Description | |---------|-------------| | `pnpm dev` | Development with hot reload | | `pnpm build` | Production build | | `pnpm typecheck` | TypeScript type checking | +| `pnpm lint` | Lint (no auto-fix) | | `pnpm lint:fix` | Lint and auto-fix | +| `pnpm format` | Format code with Prettier | | `pnpm test` | Run all tests | | `pnpm test:watch` | Watch mode | | `pnpm test:coverage` | Coverage report | +| `pnpm test:coverage:critical` | Critical path coverage | | `pnpm check` | Full quality gate (types + lint + test + build) | +| `pnpm fix` | Lint fix + format | +| `pnpm quality` | Full check + format check + knip |
--- -## TODO +## Roadmap -- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. +- [ ] CLI runtime: Run in headless/console environments (web UI), e.g. VPS, remote server - [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop -- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills) +- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents +- [ ] Planning mode to organize agent plans before execution +- [ ] Curate what context each agent sees (files, docs, MCP servers, skills) - [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models --- +## Links + +- [Homepage](https://github.com/777genius/claude_agent_teams_ui) +- [Releases](https://github.com/777genius/claude_agent_teams_ui/releases) +- [Issues](https://github.com/777genius/claude_agent_teams_ui/issues) +- [MCP Server](./mcp-server) — Use Claude Agent Teams UI tools from Cursor, Claude Desktop, and other MCP clients + +--- + ## Contributing See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](.github/CODE_OF_CONDUCT.md). @@ -219,4 +272,4 @@ IPC handlers validate all inputs with strict path containment checks. File reads ## License -[MIT](LICENSE) +[AGPL-3.0](LICENSE) diff --git a/docs/research/model-agnostic-proxy.md b/docs/research/model-agnostic-proxy.md new file mode 100644 index 00000000..7b74d6e5 --- /dev/null +++ b/docs/research/model-agnostic-proxy.md @@ -0,0 +1,467 @@ +# Model-Agnostic Proxy для Claude Code Agent Teams + +Дата исследования: 2026-03-06 + +## Цель + +Сделать Claude Code Agent Teams model-agnostic: lead остаётся на Claude, а teammates могут работать на GPT-4o, Gemini, DeepSeek, Kimi K2.5 и других моделях через прокси, который транслирует Anthropic Messages API в формат целевого провайдера. + +## Ключевой механизм + +Claude Code обращается к Anthropic Messages API (`/v1/messages`). Переменная `ANTHROPIC_BASE_URL` позволяет перенаправить запросы на локальный прокси. Прокси: + +1. Принимает запрос в формате Anthropic Messages API +2. Транслирует в формат целевого провайдера (OpenAI Chat Completions и др.) +3. Пересылает провайдеру +4. Получает SSE-стрим ответа +5. Транслирует обратно в формат Anthropic SSE-событий +6. Отдаёт Claude Code CLI как будто это ответ от Claude + +Team tools (TeamCreate, TaskCreate, SendMessage, TaskGet, TaskList, TaskUpdate, TeamDelete) исполняются **локально Claude Code CLI**. LLM только генерирует `tool_use` блоки. Значит прокси не нужно знать о team-семантике — достаточно корректно транслировать tool_use формат. + +--- + +## Исследованные проекты + +### 1. HydraTeams + +- **URL**: https://github.com/Pickle-Pixel/HydraTeams +- **Назначение**: Прокси-переводчик API специально для Agent Teams +- **Язык**: TypeScript (~580 строк, 8 файлов) +- **Зависимости**: Zero runtime dependencies (только Node.js builtins) +- **Лицензия**: Не указана явно +- **Stars**: 33 | **Forks**: 9 | **Commits**: 4 | **Создан**: 2026-02-08 + +#### Архитектура + +``` +src/ + index.ts (35) — точка входа, banner, graceful shutdown + proxy.ts (280) — HTTP-сервер, маршрутизация, retry + config.ts (95) — CLI-аргументы, env vars, Codex JWT + logger.ts (190) — логирование, идентификация сессий + translators/ + types.ts (120) — интерфейсы Anthropic + OpenAI + messages.ts (85) — конвертация истории сообщений + request.ts (65) — Anthropic req -> OpenAI Chat Completions req + request-responses.ts (145) — Anthropic req -> ChatGPT Responses API req + response.ts (185) — OpenAI SSE -> Anthropic SSE + response-responses.ts (235) — Responses API SSE -> Anthropic SSE +``` + +#### Два конвейера трансляции + +1. **OpenAI Chat Completions API** (`--provider openai`) — для GPT-4o, GPT-4o-mini, o3-mini +2. **ChatGPT Responses API** (`--provider chatgpt`) — для ChatGPT Subscription ($0 дополнительных затрат) + +#### Lead vs Teammate детекция + +- Lead: маркер `` в CLAUDE.md (попадает в system prompt) +- Teammate: фраза `"the user interacts primarily with the team lead"` в system prompt +- Lead-запросы passthrough на настоящий Anthropic API +- Teammate-запросы транслируются на целевую модель + +#### Что работает правильно + +- Tool definitions: `input_schema` -> `parameters`, `tool_choice` маппинг корректен +- tool_use блоки ассистанта -> `tool_calls` формат OpenAI +- tool_result -> `role: "tool"` сообщения +- Streaming: StreamState отслеживает blockIndex, activeToolCalls, textBlockStarted +- Правильная SSE-последовательность: message_start -> content_block_start -> content_block_delta -> content_block_stop -> message_delta -> message_stop +- Retry с exponential backoff на 429 (до 5 попыток) + +#### Найденные баги (code review) + +| # | Баг | Серьёзность | +|---|-----|-------------| +| 1 | **response-responses.ts**: `content_index` используется вместо Anthropic `blockIndex` в `response.output_text.done` — content_block_stop уйдёт с неправильным index | Высокая | +| 2 | **proxy.ts**: `shouldPassthrough()` получает `parsed.system` (может быть массив `AnthropicSystemBlock[]`) как string — `count_tokens` passthrough для lead не сработает | Средняя | +| 3 | **proxy.ts**: Non-streaming `JSON.parse(tc.function.arguments)` — если OpenAI вернёт невалидный JSON, exception убьёт весь запрос | Средняя | +| 4 | **proxy.ts**: `shouldPassthrough("*")` означает "все Claude модели", а не "всё" — контринтуитивно | Низкая | +| 5 | **logger.ts**: Warmup-запросы определяются по `toolCount === 0` — может ложно классифицировать обычные запросы | Низкая | + +#### Критические проблемы + +| Проблема | Серьёзность | +|----------|-------------| +| 0 тестов | Критично | +| Нет таймаутов на upstream — fetch() без AbortController, зависнет навсегда | Критично | +| Слушает на 0.0.0.0 — доступен в сети, релеит auth headers | Критично | +| Teammate детекция по строке "the user interacts primarily with the team lead" — сломается при обновлении Claude Code | Высокая | +| Spoofed model hardcoded `claude-sonnet-4-5-20250929` — устареет | Средняя | +| Token counting = `JSON.length / 4` — грубая заглушка | Средняя | +| Нет extended thinking — thinking блоки игнорируются | Средняя | +| Нет image/multimodal — молча теряются | Низкая | + +#### Безопасность + +- Сервер слушает на 0.0.0.0 (все интерфейсы) — в сети это дыра +- Passthrough релеит auth headers (x-api-key, authorization, cookie) к api.anthropic.com +- JWT парсинг без валидации подписи +- Нет rate limiting на входящие запросы +- Логи могут содержать API-ключи в ответах об ошибках + +#### Итоговые оценки + +| Аспект | Оценка | +|--------|:------:| +| Архитектура | 7/10 | +| API трансляция | 6/10 | +| Lead/Teammate детекция | 5/10 | +| Обработка ошибок | 4/10 | +| Streaming | 7/10 | +| Безопасность | 3/10 | +| Production readiness | 3/10 | +| **Общая** | **5/10** | + +--- + +### 2. free-claude-code + +- **URL**: https://github.com/Alishahryar1/free-claude-code +- **Назначение**: Прокси для использования Claude Code с бесплатными моделями +- **Язык**: Python (FastAPI + uvicorn) +- **Stars**: 814 | **Forks**: 95 | **Issues**: 4 | **Создан**: 2026-01-28 +- **Лицензия**: MIT +- **Тесты**: 85+ файлов, pytest, GitHub Actions CI + +#### Архитектура + +``` +server.py — точка входа (uvicorn) +api/ + app.py — FastAPI factory + lifespan + routes.py — POST /v1/messages, GET /health + detection.py — эвристики определения типа запроса + optimization_handlers.py — 5 fast-path перехватчиков + request_utils.py — подсчёт токенов (tiktoken cl100k_base) + models/anthropic.py — Pydantic модели Anthropic request +providers/ + base.py — BaseProvider (ABC) + openai_compat.py — OpenAICompatibleProvider (основная логика) + common/ + message_converter.py — Anthropic <-> OpenAI конвертер (~200 строк) + sse_builder.py — SSE event builder Anthropic формат (~300 строк) + think_parser.py — парсер тегов (~80 строк) + heuristic_tool_parser.py — парсер tool calls из текста + nvidia_nim/, open_router/, lmstudio/ — конкретные провайдеры +``` + +#### Провайдеры и модели + +##### NVIDIA NIM (бесплатно, 40 req/min) + +**Tier S (флагманы):** + +| Model ID | Thinking | Tool Calling | +|----------|:--------:|:------------:| +| `moonshotai/kimi-k2.5` | Да | Native | +| `qwen/qwen3-coder-480b-a35b-instruct` | Да | Native | +| `z-ai/glm5` | Да | Native | +| `deepseek-ai/deepseek-v3.2` | Да | Native | +| `mistralai/mistral-large-3-675b-instruct` | Да | Native | +| `minimaxai/minimax-m2.5` | Да | Native | + +**Tier A:** + +| Model ID | Thinking | Tool Calling | +|----------|:--------:|:------------:| +| `z-ai/glm4.7` | Да | Native | +| `mistralai/devstral-2-123b-instruct` | Да | Native | +| `openai/gpt-oss-120b` | Да | Native | +| `meta/llama-3.1-405b-instruct` | Нет | Native | + +**Tier B (быстрые):** + +| Model ID | Thinking | Tool Calling | +|----------|:--------:|:------------:| +| `qwen/qwen2.5-coder-32b-instruct` | Нет | Native | +| `stepfun-ai/step-3.5-flash` | Да | Native | +| `meta/llama-3.3-70b-instruct` | Нет | Native | + +Всего в каталоге NIM **185 моделей**, все бесплатные. max_tokens: 81920. + +##### OpenRouter (free модели с суффиксом `:free`) + +**С Tool Calling + Thinking:** + +| Model ID | Context | Max Output | +|----------|:-------:|:----------:| +| `openai/gpt-oss-120b:free` | 131K | 131K | +| `stepfun/step-3.5-flash:free` | 256K | 256K | +| `qwen/qwen3-coder:free` | 262K | 262K | +| `qwen/qwen3-235b-a22b-thinking-2507:free` | 131K | — | +| `z-ai/glm-4.5-air:free` | 131K | 96K | + +**С Tool Calling, без Thinking:** + +| Model ID | Context | +|----------|:-------:| +| `meta-llama/llama-3.3-70b-instruct:free` | 128K | +| `mistralai/mistral-small-3.1-24b-instruct:free` | 128K | +| `google/gemma-3-27b-it:free` | 131K | + +Всего **28 бесплатных моделей** + мета-роутер `openrouter/free`. + +##### LM Studio (полностью локально, без лимитов) + +| Модель | VRAM | Качество кода | +|--------|:----:|:---:| +| `unsloth/MiniMax-M2.5-GGUF` | 48GB+ | 7/10 | +| `unsloth/Qwen3-Coder-480B-A35B-Instruct-GGUF` | 48GB+ | 8/10 | +| `unsloth/Qwen3.5-35B-A3B-GGUF` | 24GB | 6/10 | +| `unsloth/GLM-4.7-Flash-GGUF` | 24GB | 6/10 | +| `unsloth/Qwen2.5-Coder-32B-Instruct-GGUF` | 24GB | 6/10 | + +#### Что лучше чем HydraTeams + +| Аспект | free-claude-code | HydraTeams | +|--------|:---:|:---:| +| Тесты | **85+ файлов**, CI | 0 | +| Thinking blocks | **Два пути** (native + `` парсер) | Нет | +| Token counting | **tiktoken** (реальный подсчёт) | `JSON.length / 4` | +| Provider abstraction | **ABC + наследование** | Hardcoded if/else | +| Error handling | **Graceful shutdown**, rate limiter | Базовый try/catch | +| Heuristic tool parser | **Есть** (для моделей без native tool use) | Нет | + +#### Проблемы для Agent Teams + +1. **Task tool patching** — принудительно ставит `run_in_background=False` в трёх местах. Опасная мина для team coordination +2. **Optimization interceptors** — 5 эвристик могут ложно сработать на teammate messages +3. **Общий rate limiter** — singleton `GlobalRateLimiter` (40 req/min, max_concurrency=5). 5 teammates = мгновенный bottleneck. Один 429 блокирует ВСЕХ +4. **Python 3.14 requirement** — ещё в beta, проблема для bundling +5. **tiktoken** требует Rust-скомпилированный .so — кросс-платформенная сборка сложная +6. **Нет lead/teammate разделения** — все запросы на один провайдер + +#### Bundling с Electron + +| Критерий | free-claude-code (Python) | HydraTeams (TypeScript) | +|----------|:---:|:---:| +| Bundling в Electron | PyInstaller ~100-150MB, Python 3.14, Rust deps | Прямо в main process, 0 deps | +| Размер | ~50MB минимум | ~580 строк, КБ | +| Кросс-платформа | tiktoken .so для каждой платформы | Нативный Node.js | +| Запуск | Child process + Python runtime | Просто import | + +#### Переиспользуемое ядро (~900 строк Python) + +- `message_converter.py` (~200 строк) — полностью независим +- `sse_builder.py` (~300 строк) — почти независим (убрать Task patching) +- `think_parser.py` (~80 строк) — полностью независим +- `openai_compat.py` (~250 строк) — stream_response логика + +#### Итоговые оценки + +| Критерий | Оценка | +|----------|:------:| +| Качество кода | 7/10 | +| Тесты | 8/10 | +| Bundling с Electron | 2/10 | +| Agent Teams совместимость | 3/10 | +| Адаптация под наш стек | 3/10 | + +--- + +### 3. LiteLLM Proxy + +- **URL**: https://github.com/BerriAI/litellm +- **Лицензия**: MIT (enterprise-фичи проприетарные) +- **Stars**: ~38,000 | **Forks**: ~6,200 +- **Язык**: Python +- **Провайдеры**: 100+ (OpenAI, Gemini, Bedrock, Azure, Groq, DeepSeek...) + +#### Как работает с Claude Code + +```bash +export ANTHROPIC_BASE_URL="http://0.0.0.0:4000" +export ANTHROPIC_AUTH_TOKEN="$LITELLM_MASTER_KEY" +claude --model gpt-4o +``` + +Нативный Anthropic SDK-совместимый endpoint `/messages`. Полная трансляция tool_use, streaming SSE. + +#### Почему НЕ подходит для Electron + +| Проблема | Детали | +|----------|--------| +| Python | 70+ зависимостей, Prisma с Node binaries, grpcio | +| Размер | ~2 ГБ (Docker-образ) | +| RAM | Рекомендация 8 ГБ для production | +| Bundling | Никто никогда не бандлил с desktop app | + +#### Известные баги с Claude Code + +- [#21446](https://github.com/BerriAI/litellm/issues/21446) — Gemini не работает через LiteLLM +- [#14194](https://github.com/BerriAI/litellm/issues/14194) — Bedrock thinking + tools конфликтуют +- [#12222](https://github.com/BerriAI/litellm/issues/12222) — Gemini падает на tools с optional args +- [#18730](https://github.com/BerriAI/litellm/issues/18730) — Concurrent requests обходят rate limits + +**Вердикт: enterprise-серверный gateway, не для десктопа. 1/10 для нашего кейса.** + +--- + +### 4. Bifrost (Maxim AI) + +- **URL**: https://github.com/maximhq/bifrost +- **Лицензия**: Apache 2.0 +- **Stars**: ~2,700 | **Commits**: 3,341 +- **Язык**: Go (11 мкс overhead при 5000 RPS) +- **Провайдеры**: 20+ (OpenAI, Gemini, Bedrock, Azure, Mistral, Ollama...) + +#### Bundling с Electron + +Bifrost компилируется в единый статический Go-бинарник. npm-пакет `@maximhq/bifrost` скачивает prebuilt binary с CDN под нужную платформу. + +``` +https://downloads.getmaxim.ai/bifrost/{version}/{platform}/{arch}/bifrost-http +``` + +Поддерживаемые платформы: +- darwin/arm64 (macOS Apple Silicon) +- darwin/amd64 (macOS Intel) +- linux/amd64, linux/386 +- windows/amd64, windows/arm64 + +Размер: ~30-60 MB. Запуск: `child_process.spawn(bifrostBinaryPath)`. + +**Bundling: 9/10** — скачать бинарник, положить рядом, запустить как child process. + +#### Проблемы для Agent Teams + +| Issue | Описание | Статус | +|-------|----------|--------| +| [#1164](https://github.com/maximhq/bifrost/issues/1164) | Parallel tool calls через Bedrock не работают | Открыт | +| [#1829](https://github.com/maximhq/bifrost/issues/1829) | Streaming tool call deltas мёрджатся | Закрыт | +| [#1804](https://github.com/maximhq/bifrost/issues/1804) | Streaming tool calls с агентскими клиентами не работают | Открыт | +| [#828](https://github.com/maximhq/bifrost/issues/828) | Goroutine leak при context cancellation | Открыт | +| [#1613](https://github.com/maximhq/bifrost/issues/1613) | SSE streaming от Gemini ломается | Открыт | + +Не использует anthropic-go-sdk, дублирует типы вручную (Discussion #1259). + +**Никто не тестировал Bifrost с Agent Teams.** + +#### Итоговые оценки + +| Критерий | Оценка | +|----------|:------:| +| Bundling с Electron | 9/10 | +| Agent Teams (passthrough) | 8/10 | +| Agent Teams (трансляция) | 4/10 | +| Зрелость | 7/10 | + +--- + +## Сравнение качества моделей vs Claude + +| Модель | Кодинг | vs Sonnet | vs Opus | +|--------|:------:|:---------:|:-------:| +| Kimi K2.5 (NIM) | 8/10 | ~80% | ~60% | +| Qwen3 Coder 480B (NIM) | 8/10 | ~80% | ~60% | +| GLM-5 (NIM) | 7/10 | ~70% | ~50% | +| GPT-OSS 120B (NIM/OR) | 7/10 | ~70% | ~50% | +| GLM-4.7 (NIM) | 7/10 | ~65% | ~45% | +| Step 3.5 Flash (OR) | 6/10 | ~55% | ~35% | +| Llama 3.3 70B (OR) | 5/10 | ~45% | ~30% | + +Ни одна бесплатная модель не дотягивает до Claude по качеству агентного кодинга. + +--- + +## Юридические аспекты + +### Что разрешено Anthropic + +- `ANTHROPIC_BASE_URL` — **официально поддерживается** для LLM Gateway +- Использование прокси с собственными API-ключами других провайдеров — легально +- Документация описывает LLM Gateway конфигурацию: endpoint должен реализовывать Anthropic Messages API + +### Что запрещено + +- Использование OAuth-токенов от Claude Free/Pro/Max подписок в сторонних продуктах +- Anthropic активно блокирует несанкционированное использование подписочных токенов +- Использование Claude для обучения конкурирующих моделей (Section D.4 Commercial Terms) + +### ChatGPT backend API (HydraTeams) + +- `chatgpt.com/backend-api/codex/responses` — недокументированный API +- Нарушение ToS ChatGPT при автоматизации через backend API +- Может быть заблокирован в любой момент + +--- + +## Влияние на наш проект (Claude DevTools) + +JSONL формат сессий **не меняется** — Claude Code CLI генерирует одинаковую структуру независимо от backend-модели. TeamCreate, TaskCreate, SendMessage и прочие team tools остаются теми же. Парсинг и chunk building будет работать без изменений. + +Единственное потенциальное отличие — metadata о модели в сообщениях (model field). + +--- + +## Рекомендация по реализации + +### Лучший путь: форк HydraTeams + hardening + +HydraTeams — единственный проект заточенный под Agent Teams, на TypeScript (наш стек), zero deps, встраивается в Electron main process. + +**Что нужно починить обязательно:** +1. Localhost-only binding (`127.0.0.1`) +2. AbortController + таймауты на upstream fetch +3. Баги с indices в response-responses.ts +4. Тесты на трансляцию (unit-тесты на каждый translator) +5. Убрать hardcoded spoofModel -> сделать конфигурируемым + +**Что заимствовать из free-claude-code:** +1. ThinkTagParser — переписать на TS (~50 строк) +2. Provider abstraction — интерфейс `TranslationProvider` +3. Структуру тестов +4. Heuristic tool parser для моделей без native tool calling + +**Что переосмыслить:** +- Lead/teammate детекция — маркер по строке хрупкий. Мы знаем роли из TeamDataService, можно передавать через env var `HYDRA_ROLE=lead|teammate` + +### Оценка трудозатрат + +~2-3 дня на hardening + тесты. Итого: TypeScript-пакет ~800-1000 строк с тестами, встраиваемый в Electron main process. + +### Альтернативы + +| Вариант | Надёжность | Уверенность | +|---------|:----------:|:-----------:| +| Форк HydraTeams + hardening | 7/10 | 8/10 | +| Свой proxy с нуля на TS (вдохновлённый обоими) | 8/10 | 7/10 | +| Bifrost binary + thin TS translator | 5/10 | 6/10 | +| LiteLLM как Docker sidecar | 6/10 | 7/10 | +| free-claude-code (Python child process) | 3/10 | 8/10 | + +--- + +## Другие найденные проекты + +| Проект | Описание | Применимость | +|--------|----------|:---:| +| [1rgs/claude-code-proxy](https://github.com/1rgs/claude-code-proxy) | На базе LiteLLM, BIG/SMALL model маппинг | 5/10 | +| [fuergaosi233/claude-code-proxy](https://github.com/fuergaosi233/claude-code-proxy) | Anthropic -> OpenAI конвертер | 4/10 | +| [nielspeter/claude-code-proxy](https://github.com/nielspeter/claude-code-proxy) | Легковесный бинарник, OpenRouter | 4/10 | +| [9router](https://github.com/decolua/9router) | Smart router с fallback-каскадом | 5/10 | +| [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | MCP-сервер, реимплементация Agent Teams | 3/10 | + +--- + +## Архитектурная схема целевого решения + +``` +[Lead Agent Process] + ANTHROPIC_BASE_URL=http://127.0.0.1:{port} + HYDRA_ROLE=lead + | + v +[Proxy (TypeScript, в Electron main process)] + if role=lead --> passthrough к api.anthropic.com + if role=teammate --> трансляция к целевому провайдеру + | + v +[Teammate 1] --> OpenAI API (GPT-4o) +[Teammate 2] --> NVIDIA NIM (Kimi K2.5) +[Teammate 3] --> Local (LM Studio) +``` + +Stream-json протокол (stdin/stdout между lead и teammates) **не затрагивается** — прокси работает на уровне HTTP API запросов к LLM. diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000..f6b0d7de --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,301 @@ +# @claude-team/mcp-server + +**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.** + +Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui) +- [Quick start](#quick-start) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Configuration](#configuration) +- [Tools Reference](#tools-reference) +- [Data Storage](#data-storage) +- [Development](#development) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) +- [License](#license) + +--- + +## Overview + +Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which: + +- Stores task data as JSON under `~/.claude/tasks/{teamName}/` +- Stores team config under `~/.claude/teams/{teamName}/` +- Is installed automatically when you run Claude Agent Teams UI at least once + +### Use cases + +| Scenario | Description | +|----------|-------------| +| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat | +| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use | +| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages | + +### Architecture + +``` +┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐ +│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │ +│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │ +│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │ +└─────────────────────┘ └──────────────────────┘ └─────────────────┘ +``` + +--- + +## Relationship to Claude Agent Teams UI + +| Component | Role | +|-----------|------| +| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. | +| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. | +| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. | + +Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data. + +--- + +## Quick start + +1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`) +2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build` +3. Add to Cursor MCP config (`~/.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "claude-team-tools": { + "command": "node", + "args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] + } + } +} +``` + +4. Restart Cursor. The 13 tools will appear for the AI assistant. + +--- + +## Prerequisites + +- **Node.js 20+** +- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`) + +If `teamctl.js` is missing, the server throws at startup: + +``` +teamctl.js not found at ~/.claude/tools/teamctl.js. +Make sure Claude Agent Teams UI has been run at least once, +or set the TEAMCTL_PATH environment variable. +``` + +--- + +## Installation + +### From the monorepo (development) + +```bash +cd mcp-server +pnpm install +pnpm build +``` + +### As a dependency + +```bash +pnpm add @claude-team/mcp-server +# or +npm install @claude-team/mcp-server +``` + +--- + +## Configuration + +### Cursor + +Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "claude-team-tools": { + "command": "node", + "args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] + } + } +} +``` + +With global install (`pnpm link` or `npm link`): + +```json +{ + "mcpServers": { + "claude-team-tools": { + "command": "team-mcp-server" + } + } +} +``` + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent: + +```json +{ + "mcpServers": { + "claude-team-tools": { + "command": "node", + "args": ["/absolute/path/to/mcp-server/dist/index.js"] + } + } +} +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` | + +--- + +## Tools Reference + +All tools require a `team` parameter (team name, folder under `~/.claude/teams/`). + +### Task CRUD + +| Tool | Description | +|------|-------------| +| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` | +| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) | +| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) | +| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals | +| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` | + +### Task collaboration + +| Tool | Description | +|------|-------------| +| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` | +| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected | +| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board | +| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB | + +### Kanban board + +| Tool | Description | +|------|-------------| +| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` | +| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` | + +### Code review + +| Tool | Description | +|------|-------------| +| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) | + +### Messaging + +| Tool | Description | +|------|-------------| +| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications | + +--- + +## Data Storage + +| Location | Contents | +|----------|----------| +| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) | +| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes | + +Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes). + +--- + +## Development + +### Scripts + +| Command | Description | +|---------|-------------| +| `pnpm build` | Build with tsup → `dist/index.js` | +| `pnpm dev` | Run with tsx (no build) | +| `pnpm test` | Run Vitest tests | +| `pnpm test:watch` | Watch mode | +| `pnpm typecheck` | TypeScript check | + +### Project structure + +``` +mcp-server/ +├── src/ +│ ├── index.ts # FastMCP server, registers all tools +│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess +│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout +│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.) +│ └── tools/ +│ ├── index.ts # registerAllTools() +│ ├── task-create.ts +│ ├── task-get.ts +│ ├── ... +│ └── message-send.ts +├── test/ +│ ├── tools/ # Per-tool tests +│ ├── teamctl-runner.test.ts +│ ├── output-parser.test.ts +│ └── schemas.test.ts +├── package.json +├── tsup.config.ts +└── vitest.config.ts +``` + +### Adding a new tool + +1. Create `src/tools/your-tool.ts` with `register(server, runner)` +2. Add to `ALL_TOOLS` in `src/tools/index.ts` +3. Add Zod parameters and map to `teamctl` CLI args +4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts` +5. Add tests in `test/tools/your-tool.test.ts` + +--- + +## Testing + +Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required): + +```bash +pnpm test +``` + +For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`. + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` | +| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes | +| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it | +| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) | + +--- + +## License + +Same as the parent project: [AGPL-3.0](../LICENSE). diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 00000000..ac6f5a7d --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@claude-team/mcp-server", + "version": "1.0.0", + "description": "MCP server for managing Claude Agent Teams kanban board and tasks", + "type": "module", + "main": "dist/index.js", + "bin": { + "team-mcp-server": "dist/index.js" + }, + "scripts": { + "build": "tsup", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "fastmcp": "^3.34.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.8.2", + "vitest": "^3.1.4", + "@types/node": "^22.15.18" + }, + "engines": { + "node": ">=20" + } +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 00000000..a30a5b06 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,20 @@ +import { FastMCP } from 'fastmcp'; +import { TeamctlRunner } from './teamctl-runner.js'; +import { registerAllTools } from './tools/index.js'; + +const server = new FastMCP({ + name: 'claude-team-tools', + version: '1.0.0', + instructions: `MCP server for managing Claude Agent Teams kanban board and tasks. + +Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging. +All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI. + +Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`, +}); + +const runner = new TeamctlRunner(); + +registerAllTools(server, runner); + +server.start({ transportType: 'stdio' }); diff --git a/mcp-server/src/output-parser.ts b/mcp-server/src/output-parser.ts new file mode 100644 index 00000000..e2b7c1a5 --- /dev/null +++ b/mcp-server/src/output-parser.ts @@ -0,0 +1,52 @@ +/** + * Parses teamctl stdout into structured results. + * + * teamctl outputs in three formats: + * 1. JSON — task create/get/list, attach, message send, kanban reviewers list + * 2. "OK ..." text — status changes, comments, links, kanban moves, reviews + * 3. Plain text — task briefing (multi-line human-readable report) + */ + +/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */ +export function parseJsonOutput(stdout: string): T { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error('Empty output from teamctl (expected JSON)'); + } + try { + return JSON.parse(trimmed) as T; + } catch { + throw new Error( + `Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`, + ); + } +} + +/** Parse "OK ..." acknowledgment lines from teamctl */ +export function parseOkOutput(stdout: string): string { + const trimmed = stdout.trim(); + if (trimmed.startsWith('OK ')) { + return trimmed.slice(3); // Strip "OK " prefix, keep the rest + } + // Some commands output just "OK\n" + if (trimmed === 'OK') { + return 'OK'; + } + // Return as-is if format is unexpected — don't throw + return trimmed; +} + +/** Return plain text as-is (briefing, help output) */ +export function parseTextOutput(stdout: string): string { + return stdout.trim(); +} + +/** + * Format teamctl stderr into a user-friendly error message. + * teamctl writes errors to stderr via `die(message)` and exits with code 1. + */ +export function formatError(stderr: string, stdout: string): string { + const msg = stderr.trim() || stdout.trim(); + if (!msg) return 'Unknown teamctl error'; + return msg; +} diff --git a/mcp-server/src/schemas.ts b/mcp-server/src/schemas.ts new file mode 100644 index 00000000..cc808e0b --- /dev/null +++ b/mcp-server/src/schemas.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import { sep, isAbsolute } from 'node:path'; + +// --------------------------------------------------------------------------- +// Identifiers +// --------------------------------------------------------------------------- + +/** + * Matches teamctl's `isSafePathSegment()`: + * rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..' + */ +const safePathSegment = (label: string) => + z + .string() + .min(1) + .max(128) + .refine( + (v) => + v.trim().length > 0 && + v !== '.' && + v !== '..' && + !v.includes('/') && + !v.includes('\\') && + !v.includes('\0') && + !v.includes('..'), + { message: `Invalid ${label}: must be a safe path segment` }, + ); + +/** Team name — folder inside `~/.claude/teams/` */ +export const teamNameSchema = safePathSegment('team name').describe( + 'Team name (folder in ~/.claude/teams/)', +); + +/** Numeric task ID produced by teamctl's highwatermark counter */ +export const taskIdSchema = z + .string() + .regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")') + .describe('Numeric task ID'); + +/** Team member name — folder inside inboxes, safe path segment */ +export const memberNameSchema = safePathSegment('member name').describe( + 'Team member name', +); + +// --------------------------------------------------------------------------- +// Enums — match teamctl's normalizeStatus / normalizeColumn / etc. +// --------------------------------------------------------------------------- + +export const taskStatusSchema = z.enum([ + 'pending', + 'in_progress', + 'completed', + 'deleted', +]); + +export const kanbanColumnSchema = z + .enum(['review', 'approved']) + .describe('Kanban column to move task to'); + +export const clarificationSchema = z + .enum(['lead', 'user', 'clear']) + .describe('Who needs to clarify: lead, user, or clear the flag'); + +export const linkTypeSchema = z + .enum(['blocked-by', 'blocks', 'related']) + .describe('Relationship type between tasks'); + +export const linkOperationSchema = z.enum(['link', 'unlink']); + +export const reviewDecisionSchema = z.enum(['approve', 'request-changes']); + +export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']); + +// --------------------------------------------------------------------------- +// Composite schemas +// --------------------------------------------------------------------------- + +/** Comma-separated task IDs sent as a single CLI argument */ +export const taskIdsArraySchema = z + .array(taskIdSchema) + .describe('Array of task IDs (e.g. ["1", "3"])'); + +// --------------------------------------------------------------------------- +// File / attachment schemas — defence-in-depth for CLI arguments +// --------------------------------------------------------------------------- + +/** Absolute file path without traversal sequences */ +export const filePathSchema = z + .string() + .min(1) + .refine((p) => isAbsolute(p), { message: 'Path must be absolute' }) + .refine((p) => !p.split(sep).includes('..'), { + message: 'Path must not contain traversal sequences (..)', + }) + .refine((p) => !p.includes('\0'), { + message: 'Path must not contain null bytes', + }); + +/** Safe filename — no path separators, no null bytes, reasonable length */ +export const safeFilenameSchema = z + .string() + .min(1) + .max(255) + .refine( + (f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'), + { message: 'Filename must not contain path separators or null bytes' }, + ); + +/** MIME type — standard type/subtype format */ +export const mimeTypeSchema = z + .string() + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/, + 'Invalid MIME type format (expected type/subtype)', + ); diff --git a/mcp-server/src/teamctl-runner.ts b/mcp-server/src/teamctl-runner.ts new file mode 100644 index 00000000..d9892106 --- /dev/null +++ b/mcp-server/src/teamctl-runner.ts @@ -0,0 +1,155 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TeamctlResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface ITeamctlRunner { + execute(args: string[]): Promise; +} + +export interface TeamctlRunnerOptions { + /** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */ + teamctlPath?: string; + /** Max concurrent subprocess calls (default: 5) */ + maxConcurrent?: number; + /** Subprocess timeout in ms (default: 10 000) */ + timeoutMs?: number; +} + +// --------------------------------------------------------------------------- +// Semaphore — limits concurrent subprocess spawns +// --------------------------------------------------------------------------- + +class Semaphore { + private current = 0; + private queue: Array<() => void> = []; + + constructor(private readonly max: number) {} + + async acquire(): Promise { + if (this.current < this.max) { + this.current++; + return; + } + return new Promise((resolve) => { + this.queue.push(() => { + this.current++; + resolve(); + }); + }); + } + + release(): void { + this.current--; + const next = this.queue.shift(); + if (next) next(); + } +} + +// --------------------------------------------------------------------------- +// TeamctlRunner +// --------------------------------------------------------------------------- + +export class TeamctlRunner implements ITeamctlRunner { + readonly teamctlPath: string; + private readonly timeoutMs: number; + private readonly semaphore: Semaphore; + + constructor(options?: TeamctlRunnerOptions) { + this.teamctlPath = resolveTeamctlPath(options?.teamctlPath); + this.timeoutMs = options?.timeoutMs ?? 10_000; + this.semaphore = new Semaphore(options?.maxConcurrent ?? 5); + + // Fail fast if teamctl.js doesn't exist + if (!existsSync(this.teamctlPath)) { + throw new Error( + `teamctl.js not found at ${this.teamctlPath}. ` + + 'Make sure Claude Agent Teams UI has been run at least once, ' + + 'or set the TEAMCTL_PATH environment variable.', + ); + } + } + + async execute(args: string[]): Promise { + await this.semaphore.acquire(); + try { + return await this.spawn(args); + } finally { + this.semaphore.release(); + } + } + + private spawn(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', [this.teamctlPath, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: this.timeoutMs, + env: { ...process.env }, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + + let settled = false; + + child.on('error', (err) => { + if (!settled) { + settled = true; + reject(new Error(`Failed to spawn teamctl: ${err.message}`)); + } + }); + + child.on('close', (code, signal) => { + if (settled) return; + settled = true; + + const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); + const stderr = Buffer.concat(stderrChunks).toString('utf-8'); + + if (signal === 'SIGTERM') { + const partial = stdout.slice(0, 500) || stderr.slice(0, 500); + reject( + new Error( + `teamctl timed out after ${this.timeoutMs}ms` + + (partial ? `. Partial output: ${partial}` : ''), + ), + ); + return; + } + + resolve({ + stdout, + stderr, + exitCode: code ?? 1, + }); + }); + }); + } +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +function resolveTeamctlPath(explicit?: string): string { + if (explicit) return explicit; + + const fromEnv = process.env['TEAMCTL_PATH']; + if (fromEnv) return fromEnv; + + // Default: ~/.claude/tools/teamctl.js + return join(homedir(), '.claude', 'tools', 'teamctl.js'); +} diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts new file mode 100644 index 00000000..6a4da6f9 --- /dev/null +++ b/mcp-server/src/tools/index.ts @@ -0,0 +1,42 @@ +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; + +import { register as taskCreate } from './task-create.js'; +import { register as taskSetStatus } from './task-set-status.js'; +import { register as taskSetOwner } from './task-set-owner.js'; +import { register as taskGet } from './task-get.js'; +import { register as taskList } from './task-list.js'; +import { register as taskComment } from './task-comment.js'; +import { register as taskLink } from './task-link.js'; +import { register as taskBriefing } from './task-briefing.js'; +import { register as taskAttach } from './task-attach.js'; +import { register as kanbanMove } from './kanban-move.js'; +import { register as kanbanReviewers } from './kanban-reviewers.js'; +import { register as reviewAction } from './review-action.js'; +import { register as messageSend } from './message-send.js'; + +const ALL_TOOLS = [ + taskCreate, + taskSetStatus, + taskSetOwner, + taskGet, + taskList, + taskComment, + taskLink, + taskBriefing, + taskAttach, + kanbanMove, + kanbanReviewers, + reviewAction, + messageSend, +] as const; + +/** + * Register all 13 MCP tools with the server. + * Each tool wraps a teamctl CLI command via the runner. + */ +export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void { + for (const register of ALL_TOOLS) { + register(server, runner); + } +} diff --git a/mcp-server/src/tools/kanban-move.ts b/mcp-server/src/tools/kanban-move.ts new file mode 100644 index 00000000..565a836f --- /dev/null +++ b/mcp-server/src/tools/kanban-move.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'kanban_move', + description: `Move a task to a kanban column or clear it from the kanban board. + +Columns: "review" (awaiting code review) or "approved" (review passed). +Use operation "clear" to remove a task from the kanban board (returns to status-based display). +Moving to a kanban column also sets the task status to "completed".`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'), + column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'), + }), + execute: async (args) => { + let cliArgs: string[]; + + if (args.operation === 'set-column') { + if (!args.column) { + throw new UserError('column is required when operation is "set-column"'); + } + cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column]; + } else { + cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id]; + } + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/kanban-reviewers.ts b/mcp-server/src/tools/kanban-reviewers.ts new file mode 100644 index 00000000..03569247 --- /dev/null +++ b/mcp-server/src/tools/kanban-reviewers.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput, parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'kanban_reviewers', + description: `Manage the kanban board's reviewer list. + +Operations: +- "list": returns JSON array of reviewer names +- "add": adds a reviewer (name required) +- "remove": removes a reviewer (name required)`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + operation: reviewerOperationSchema.describe('"list", "add", or "remove"'), + name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'), + }), + execute: async (args) => { + if (args.operation !== 'list' && !args.name) { + throw new UserError(`name is required for "${args.operation}" operation`); + } + + const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation]; + if (args.name) cliArgs.push(args.name); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`); + } + + // "list" returns JSON array, "add"/"remove" return "OK ..." text + if (args.operation === 'list') { + return parseJsonOutput(result.stdout); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/message-send.ts b/mcp-server/src/tools/message-send.ts new file mode 100644 index 00000000..0693bd33 --- /dev/null +++ b/mcp-server/src/tools/message-send.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput } from '../output-parser.js'; +import { teamNameSchema, memberNameSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'message_send', + description: `Send an inbox message to a team member. Returns delivery confirmation JSON. + +Messages appear in the member's inbox and can trigger notifications. +The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + to: memberNameSchema.describe('Recipient member name'), + text: z.string().min(1).max(10000).describe('Message text'), + summary: z.string().max(200).optional().describe('Short summary for notification preview'), + from: memberNameSchema.optional().describe('Sender name'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text]; + + if (args.summary) cliArgs.push('--summary', args.summary); + if (args.from) cliArgs.push('--from', args.from); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseJsonOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/review-action.ts b/mcp-server/src/tools/review-action.ts new file mode 100644 index 00000000..64bad45d --- /dev/null +++ b/mcp-server/src/tools/review-action.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'review_action', + description: `Approve a task or request changes. + +- "approve": marks the task as approved in kanban, optionally posts a review comment +- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + decision: reviewDecisionSchema.describe('"approve" or "request-changes"'), + comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'), + from: memberNameSchema.optional().describe('Reviewer name'), + notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'), + }), + execute: async (args) => { + if (args.decision === 'request-changes' && !args.comment) { + throw new UserError('comment is required when requesting changes'); + } + + const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id]; + + // approve uses --note for optional comment; request-changes uses --comment + if (args.decision === 'request-changes' && args.comment) { + cliArgs.push('--comment', args.comment); + } else if (args.decision === 'approve' && args.comment) { + cliArgs.push('--note', args.comment); + } + if (args.from) cliArgs.push('--from', args.from); + if (args.notify_owner) cliArgs.push('--notify-owner'); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-attach.ts b/mcp-server/src/tools/task-attach.ts new file mode 100644 index 00000000..a0e9830d --- /dev/null +++ b/mcp-server/src/tools/task-attach.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_attach', + description: `Attach a file to a task. Returns attachment metadata JSON. + +Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + file: filePathSchema.describe('Absolute path to the file to attach'), + filename: safeFilenameSchema.optional().describe('Override stored filename'), + mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'), + mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'), + from: memberNameSchema.optional().describe('Uploader name'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file]; + + if (args.filename) cliArgs.push('--filename', args.filename); + if (args.mime_type) cliArgs.push('--mime-type', args.mime_type); + if (args.mode) cliArgs.push('--mode', args.mode); + if (args.from) cliArgs.push('--from', args.from); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseJsonOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-briefing.ts b/mcp-server/src/tools/task-briefing.ts new file mode 100644 index 00000000..9273a6d7 --- /dev/null +++ b/mcp-server/src/tools/task-briefing.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseTextOutput } from '../output-parser.js'; +import { teamNameSchema, memberNameSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_briefing', + description: `Generate a text briefing for a team member showing their assigned tasks vs the team board. + +Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`, + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + member: memberNameSchema.describe('Member name to generate briefing for'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member]; + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseTextOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-comment.ts b/mcp-server/src/tools/task-comment.ts new file mode 100644 index 00000000..951ef0a9 --- /dev/null +++ b/mcp-server/src/tools/task-comment.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_comment', + description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + text: z.string().min(1).max(10000).describe('Comment text'), + from: memberNameSchema.optional().describe('Author name (skips self-notification)'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text]; + + if (args.from) cliArgs.push('--from', args.from); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-create.ts b/mcp-server/src/tools/task-create.ts new file mode 100644 index 00000000..5c08e4e7 --- /dev/null +++ b/mcp-server/src/tools/task-create.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput } from '../output-parser.js'; +import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_create', + description: `Create a new task in a team's task board. Returns the created task JSON. + +Behavior: +- If owner is set and no blockers → status defaults to "in_progress" +- If blocked_by specified → status defaults to "pending" (even with owner) +- If notify is true, sends an inbox notification to the assigned owner`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + subject: z.string().min(1).max(500).describe('Task title'), + description: z.string().max(5000).optional().describe('Detailed task description'), + owner: memberNameSchema.optional().describe('Assign to a team member'), + blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'), + related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'), + status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'), + active_form: z.string().max(200).optional().describe('Active form hint for CLI display'), + notify: z.boolean().optional().describe('Send inbox notification to owner'), + from: memberNameSchema.optional().describe('Author name for notifications'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject]; + + if (args.description) cliArgs.push('--description', args.description); + if (args.owner) cliArgs.push('--owner', args.owner); + if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(',')); + if (args.related?.length) cliArgs.push('--related', args.related.join(',')); + if (args.status) cliArgs.push('--status', args.status); + if (args.active_form) cliArgs.push('--activeForm', args.active_form); + if (args.notify) cliArgs.push('--notify'); + if (args.from) cliArgs.push('--from', args.from); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseJsonOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-get.ts b/mcp-server/src/tools/task-get.ts new file mode 100644 index 00000000..cf56ec6d --- /dev/null +++ b/mcp-server/src/tools/task-get.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_get', + description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`, + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'get', args.task_id]; + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseJsonOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-link.ts b/mcp-server/src/tools/task-link.ts new file mode 100644 index 00000000..0d43021c --- /dev/null +++ b/mcp-server/src/tools/task-link.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { + teamNameSchema, + taskIdSchema, + linkTypeSchema, + linkOperationSchema, +} from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_link', + description: `Link or unlink task dependencies. + +Relationship types: +- "blocked-by": this task is blocked by the target +- "blocks": this task blocks the target +- "related": non-blocking relationship + +Links are bidirectional: linking A blocked-by B also sets B blocks A. +Circular dependencies are detected and rejected.`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'), + relationship: linkTypeSchema, + target_id: taskIdSchema.describe('The other task ID to link/unlink'), + }), + execute: async (args) => { + const cliArgs = [ + '--team', args.team, + 'task', args.operation, + args.task_id, + `--${args.relationship}`, args.target_id, + ]; + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-list.ts b/mcp-server/src/tools/task-list.ts new file mode 100644 index 00000000..37c05bfd --- /dev/null +++ b/mcp-server/src/tools/task-list.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseJsonOutput } from '../output-parser.js'; +import { teamNameSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_list', + description: `List all tasks for a team. Returns a JSON array of task objects. + +Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`, + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'list']; + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseJsonOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-set-owner.ts b/mcp-server/src/tools/task-set-owner.ts new file mode 100644 index 00000000..4cfd04bf --- /dev/null +++ b/mcp-server/src/tools/task-set-owner.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_set_owner', + description: `Assign a task to a team member or clear the assignment. + +Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'), + notify: z.boolean().optional().describe('Send inbox notification to new owner'), + from: memberNameSchema.optional().describe('Author name for notification'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner]; + + if (args.notify) cliArgs.push('--notify'); + if (args.from) cliArgs.push('--from', args.from); + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/src/tools/task-set-status.ts b/mcp-server/src/tools/task-set-status.ts new file mode 100644 index 00000000..a5b96294 --- /dev/null +++ b/mcp-server/src/tools/task-set-status.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { UserError } from 'fastmcp'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner } from '../teamctl-runner.js'; +import { parseOkOutput } from '../output-parser.js'; +import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js'; + +export function register(server: FastMCP, runner: ITeamctlRunner): void { + server.addTool({ + name: 'task_set_status', + description: `Change the status of a task. + +Valid transitions: pending → in_progress → completed → deleted (and back). +Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete". +Records status history and tracks work intervals (time spent in_progress).`, + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + parameters: z.object({ + team: teamNameSchema, + task_id: taskIdSchema, + status: taskStatusSchema.describe('New status for the task'), + }), + execute: async (args) => { + const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status]; + + const result = await runner.execute(cliArgs); + if (result.exitCode !== 0) { + throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`); + } + return parseOkOutput(result.stdout); + }, + }); +} diff --git a/mcp-server/test/output-parser.test.ts b/mcp-server/test/output-parser.test.ts new file mode 100644 index 00000000..52f8179f --- /dev/null +++ b/mcp-server/test/output-parser.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + parseJsonOutput, + parseOkOutput, + parseTextOutput, + formatError, +} from '../src/output-parser.js'; + +describe('parseJsonOutput', () => { + it('parses valid JSON object', () => { + const input = '{"id":"42","subject":"Fix bug"}\n'; + expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' }); + }); + + it('parses valid JSON array', () => { + const input = '[{"id":"1"},{"id":"2"}]\n'; + expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]); + }); + + it('trims whitespace', () => { + const input = ' \n {"ok":true} \n '; + expect(parseJsonOutput(input)).toEqual({ ok: true }); + }); + + it('throws on empty output', () => { + expect(() => parseJsonOutput('')).toThrow('Empty output'); + expect(() => parseJsonOutput(' \n ')).toThrow('Empty output'); + }); + + it('throws on invalid JSON', () => { + expect(() => parseJsonOutput('not json')).toThrow('Failed to parse'); + }); +}); + +describe('parseOkOutput', () => { + it('strips "OK " prefix', () => { + expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed'); + }); + + it('handles bare "OK"', () => { + expect(parseOkOutput('OK\n')).toBe('OK'); + }); + + it('returns as-is for unexpected format', () => { + expect(parseOkOutput('Something else')).toBe('Something else'); + }); + + it('trims whitespace', () => { + expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared'); + }); +}); + +describe('parseTextOutput', () => { + it('trims and returns text', () => { + const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n'; + expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug'); + }); + + it('handles empty string', () => { + expect(parseTextOutput('')).toBe(''); + }); +}); + +describe('formatError', () => { + it('uses stderr when available', () => { + expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42'); + }); + + it('falls back to stdout', () => { + expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error'); + }); + + it('returns default for empty', () => { + expect(formatError('', '')).toBe('Unknown teamctl error'); + }); +}); diff --git a/mcp-server/test/schemas.test.ts b/mcp-server/test/schemas.test.ts new file mode 100644 index 00000000..cfd8b5f6 --- /dev/null +++ b/mcp-server/test/schemas.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { + teamNameSchema, + taskIdSchema, + memberNameSchema, + taskStatusSchema, + kanbanColumnSchema, + clarificationSchema, + linkTypeSchema, + linkOperationSchema, + reviewDecisionSchema, + reviewerOperationSchema, + taskIdsArraySchema, + filePathSchema, + safeFilenameSchema, + mimeTypeSchema, +} from '../src/schemas.js'; + +describe('teamNameSchema', () => { + it('accepts valid team names', () => { + expect(teamNameSchema.parse('acme')).toBe('acme'); + expect(teamNameSchema.parse('my-team')).toBe('my-team'); + expect(teamNameSchema.parse('My_Team')).toBe('My_Team'); + expect(teamNameSchema.parse('team123')).toBe('team123'); + }); + + it('rejects empty', () => { + expect(() => teamNameSchema.parse('')).toThrow(); + }); + + it('rejects path traversal', () => { + expect(() => teamNameSchema.parse('..')).toThrow(); + expect(() => teamNameSchema.parse('.')).toThrow(); + expect(() => teamNameSchema.parse('a/b')).toThrow(); + expect(() => teamNameSchema.parse('a\\b')).toThrow(); + expect(() => teamNameSchema.parse('a..b')).toThrow(); + }); + + it('rejects null bytes', () => { + expect(() => teamNameSchema.parse('a\0b')).toThrow(); + }); + + it('rejects too long', () => { + expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow(); + }); +}); + +describe('taskIdSchema', () => { + it('accepts numeric IDs', () => { + expect(taskIdSchema.parse('1')).toBe('1'); + expect(taskIdSchema.parse('42')).toBe('42'); + expect(taskIdSchema.parse('1234567890')).toBe('1234567890'); + }); + + it('accepts zero', () => { + expect(taskIdSchema.parse('0')).toBe('0'); + }); + + it('accepts leading zeros', () => { + expect(taskIdSchema.parse('007')).toBe('007'); + }); + + it('rejects non-numeric', () => { + expect(() => taskIdSchema.parse('abc')).toThrow(); + expect(() => taskIdSchema.parse('')).toThrow(); + expect(() => taskIdSchema.parse('1.5')).toThrow(); + expect(() => taskIdSchema.parse('-1')).toThrow(); + }); + + it('rejects too long', () => { + expect(() => taskIdSchema.parse('12345678901')).toThrow(); + }); +}); + +describe('memberNameSchema', () => { + it('accepts valid member names', () => { + expect(memberNameSchema.parse('alice')).toBe('alice'); + expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith'); + expect(memberNameSchema.parse('user_1')).toBe('user_1'); + }); + + it('rejects path traversal', () => { + expect(() => memberNameSchema.parse('..')).toThrow(); + expect(() => memberNameSchema.parse('a/b')).toThrow(); + expect(() => memberNameSchema.parse('a\\b')).toThrow(); + expect(() => memberNameSchema.parse('a..b')).toThrow(); + }); + + it('rejects null bytes', () => { + expect(() => memberNameSchema.parse('a\0b')).toThrow(); + }); + + it('rejects too long', () => { + expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow(); + }); + + it('rejects empty', () => { + expect(() => memberNameSchema.parse('')).toThrow(); + }); +}); + +describe('enum schemas', () => { + it('taskStatusSchema accepts valid values', () => { + expect(taskStatusSchema.parse('pending')).toBe('pending'); + expect(taskStatusSchema.parse('in_progress')).toBe('in_progress'); + expect(taskStatusSchema.parse('completed')).toBe('completed'); + expect(taskStatusSchema.parse('deleted')).toBe('deleted'); + expect(() => taskStatusSchema.parse('invalid')).toThrow(); + }); + + it('kanbanColumnSchema accepts valid values', () => { + expect(kanbanColumnSchema.parse('review')).toBe('review'); + expect(kanbanColumnSchema.parse('approved')).toBe('approved'); + expect(() => kanbanColumnSchema.parse('todo')).toThrow(); + expect(() => kanbanColumnSchema.parse('')).toThrow(); + }); + + it('clarificationSchema accepts valid values', () => { + expect(clarificationSchema.parse('lead')).toBe('lead'); + expect(clarificationSchema.parse('user')).toBe('user'); + expect(clarificationSchema.parse('clear')).toBe('clear'); + expect(() => clarificationSchema.parse('nobody')).toThrow(); + }); + + it('linkTypeSchema accepts valid values', () => { + expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by'); + expect(linkTypeSchema.parse('blocks')).toBe('blocks'); + expect(linkTypeSchema.parse('related')).toBe('related'); + }); + + it('linkOperationSchema accepts valid values', () => { + expect(linkOperationSchema.parse('link')).toBe('link'); + expect(linkOperationSchema.parse('unlink')).toBe('unlink'); + }); + + it('reviewDecisionSchema accepts valid values', () => { + expect(reviewDecisionSchema.parse('approve')).toBe('approve'); + expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes'); + }); + + it('reviewerOperationSchema accepts valid values', () => { + expect(reviewerOperationSchema.parse('list')).toBe('list'); + expect(reviewerOperationSchema.parse('add')).toBe('add'); + expect(reviewerOperationSchema.parse('remove')).toBe('remove'); + }); +}); + +describe('taskIdsArraySchema', () => { + it('accepts valid arrays', () => { + expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']); + expect(taskIdsArraySchema.parse([])).toEqual([]); + }); + + it('rejects arrays with invalid IDs', () => { + expect(() => taskIdsArraySchema.parse(['abc'])).toThrow(); + expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow(); + }); +}); + +describe('filePathSchema', () => { + it('accepts absolute paths', () => { + expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt'); + expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf'); + }); + + it('rejects relative paths', () => { + expect(() => filePathSchema.parse('relative/path.txt')).toThrow(); + expect(() => filePathSchema.parse('./file.txt')).toThrow(); + }); + + it('rejects path traversal', () => { + expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow(); + expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow(); + }); + + it('rejects null bytes', () => { + expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow(); + }); + + it('rejects empty', () => { + expect(() => filePathSchema.parse('')).toThrow(); + }); +}); + +describe('safeFilenameSchema', () => { + it('accepts valid filenames', () => { + expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf'); + expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz'); + }); + + it('rejects path separators', () => { + expect(() => safeFilenameSchema.parse('../../evil')).toThrow(); + expect(() => safeFilenameSchema.parse('dir/file')).toThrow(); + expect(() => safeFilenameSchema.parse('dir\\file')).toThrow(); + }); + + it('rejects null bytes', () => { + expect(() => safeFilenameSchema.parse('file\0name')).toThrow(); + }); + + it('rejects too long', () => { + expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow(); + }); +}); + +describe('mimeTypeSchema', () => { + it('accepts valid MIME types', () => { + expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf'); + expect(mimeTypeSchema.parse('image/png')).toBe('image/png'); + expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain'); + expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream'); + }); + + it('rejects invalid formats', () => { + expect(() => mimeTypeSchema.parse('invalid')).toThrow(); + expect(() => mimeTypeSchema.parse('/pdf')).toThrow(); + expect(() => mimeTypeSchema.parse('application/')).toThrow(); + expect(() => mimeTypeSchema.parse('')).toThrow(); + }); +}); diff --git a/mcp-server/test/teamctl-runner.test.ts b/mcp-server/test/teamctl-runner.test.ts new file mode 100644 index 00000000..54b703f7 --- /dev/null +++ b/mcp-server/test/teamctl-runner.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TeamctlRunner } from '../src/teamctl-runner.js'; +import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js'; + +// We can't easily test the real subprocess without teamctl.js installed, +// so we test the interface contract and error handling. + +describe('TeamctlRunner', () => { + it('throws if teamctl.js does not exist', () => { + expect( + () => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }), + ).toThrow('teamctl.js not found'); + }); + + it('resolves path from TEAMCTL_PATH env', () => { + const original = process.env['TEAMCTL_PATH']; + try { + process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js'; + // Will throw because file doesn't exist, but we can check the error message + expect( + () => new TeamctlRunner(), + ).toThrow('/tmp/test-teamctl.js'); + } finally { + if (original !== undefined) { + process.env['TEAMCTL_PATH'] = original; + } else { + delete process.env['TEAMCTL_PATH']; + } + } + }); +}); + +// Mock runner for tool tests +export function createMockRunner( + responses: Map | TeamctlResult, +): ITeamctlRunner { + return { + execute: vi.fn(async (args: string[]): Promise => { + if (responses instanceof Map) { + const key = args.join(' '); + const result = responses.get(key); + if (result) return result; + // Fallback: check if any key is a prefix + for (const [k, v] of responses) { + if (key.startsWith(k)) return v; + } + return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 }; + } + return responses; + }), + }; +} + +describe('ITeamctlRunner interface', () => { + it('mock runner returns success', async () => { + const runner = createMockRunner({ + stdout: '{"id":"1","subject":"Test"}\n', + stderr: '', + exitCode: 0, + }); + + const result = await runner.execute(['--team', 'test', 'task', 'create']); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toHaveProperty('id', '1'); + }); + + it('mock runner returns error', async () => { + const runner = createMockRunner({ + stdout: '', + stderr: 'Task not found: #99\n', + exitCode: 1, + }); + + const result = await runner.execute(['--team', 'test', 'task', 'get', '99']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Task not found'); + }); +}); diff --git a/mcp-server/test/tools/kanban-move.test.ts b/mcp-server/test/tools/kanban-move.test.ts new file mode 100644 index 00000000..e0468131 --- /dev/null +++ b/mcp-server/test/tools/kanban-move.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/kanban-move.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('kanban_move', () => { + function setup(response = ok('OK kanban #1 column=review\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('kanban_move')! }; + } + + it('builds set-column CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'kanban', 'set-column', '1', 'review', + ]); + }); + + it('builds clear CLI args', async () => { + const { runner, tool } = setup(ok('OK kanban #1 cleared\n')); + await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'kanban', 'clear', '1', + ]); + }); + + it('throws UserError when set-column called without column', async () => { + const { tool } = setup(); + await expect( + tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }), + ).rejects.toThrow('column is required'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('task not found')); + await expect( + tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }), + ).rejects.toThrow('Failed to update kanban'); + }); +}); diff --git a/mcp-server/test/tools/kanban-reviewers.test.ts b/mcp-server/test/tools/kanban-reviewers.test.ts new file mode 100644 index 00000000..ee854535 --- /dev/null +++ b/mcp-server/test/tools/kanban-reviewers.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/kanban-reviewers.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('kanban_reviewers', () => { + function setup(response = ok('["alice","bob"]')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('kanban_reviewers')! }; + } + + it('builds list CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', operation: 'list' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'kanban', 'reviewers', 'list', + ]); + }); + + it('returns JSON array for list', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', operation: 'list' }); + expect(result).toEqual(['alice', 'bob']); + }); + + it('builds add CLI args with name', async () => { + const { runner, tool } = setup(ok('OK reviewer added\n')); + await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie', + ]); + }); + + it('returns OK text for add/remove', async () => { + const { tool } = setup(ok('OK reviewer added\n')); + const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); + expect(result).toBe('reviewer added'); + }); + + it('throws UserError when add called without name', async () => { + const { tool } = setup(); + await expect( + tool.execute({ team: 'acme', operation: 'add' }), + ).rejects.toThrow('name is required'); + }); + + it('throws UserError when remove called without name', async () => { + const { tool } = setup(); + await expect( + tool.execute({ team: 'acme', operation: 'remove' }), + ).rejects.toThrow('name is required'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('reviewer not found')); + await expect( + tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }), + ).rejects.toThrow('Failed to manage reviewers'); + }); +}); diff --git a/mcp-server/test/tools/message-send.test.ts b/mcp-server/test/tools/message-send.test.ts new file mode 100644 index 00000000..8bb206e8 --- /dev/null +++ b/mcp-server/test/tools/message-send.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/message-send.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('message_send', () => { + const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}'; + + function setup(response = ok(deliveryJson)) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('message_send')! }; + } + + it('builds minimal CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!', + ]); + }); + + it('includes summary and from flags', async () => { + const { runner, tool } = setup(); + await tool.execute({ + team: 'acme', to: 'alice', text: 'Task done', + summary: 'Completed task #1', from: 'bob', + }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--summary'); + expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1'); + expect(args).toContain('--from'); + expect(args[args.indexOf('--from') + 1]).toBe('bob'); + }); + + it('returns parsed JSON', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); + expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' }); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('recipient inbox not found')); + await expect( + tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }), + ).rejects.toThrow('Failed to send message'); + }); +}); diff --git a/mcp-server/test/tools/register-all.test.ts b/mcp-server/test/tools/register-all.test.ts new file mode 100644 index 00000000..d562525e --- /dev/null +++ b/mcp-server/test/tools/register-all.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { registerAllTools } from '../../src/tools/index.js'; +import { createMockRunner, createMockServer } from './test-helpers.js'; + +describe('registerAllTools', () => { + it('registers exactly 13 tools', () => { + const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); + const { server, tools } = createMockServer(); + registerAllTools(server, runner); + expect(tools.size).toBe(13); + }); + + it('registers all expected tool names', () => { + const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); + const { server, tools } = createMockServer(); + registerAllTools(server, runner); + + const expected = [ + 'task_create', 'task_set_status', 'task_set_owner', + 'task_get', 'task_list', 'task_comment', 'task_link', + 'task_briefing', 'task_attach', 'kanban_move', + 'kanban_reviewers', 'review_action', 'message_send', + ]; + for (const name of expected) { + expect(tools.has(name), `missing tool: ${name}`).toBe(true); + } + }); +}); diff --git a/mcp-server/test/tools/review-action.test.ts b/mcp-server/test/tools/review-action.test.ts new file mode 100644 index 00000000..952f1ae9 --- /dev/null +++ b/mcp-server/test/tools/review-action.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/review-action.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('review_action', () => { + function setup(response = ok('OK review #1 approved\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('review_action')! }; + } + + it('builds approve CLI args (no comment)', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'review', 'approve', '1', + ]); + }); + + it('builds approve CLI args with --note (not --comment)', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' }); + const args = runner.execute.mock.calls[0]![0] as string[]; + // approve uses --note, NOT --comment + expect(args).toContain('--note'); + expect(args[args.indexOf('--note') + 1]).toBe('LGTM'); + expect(args).not.toContain('--comment'); + }); + + it('builds request-changes CLI args with --comment (not --note)', async () => { + const { runner, tool } = setup(ok('OK review #1 requested changes\n')); + await tool.execute({ + team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests', + }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--comment'); + expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests'); + expect(args).not.toContain('--note'); + }); + + it('throws when request-changes has no comment', async () => { + const { tool } = setup(); + await expect( + tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }), + ).rejects.toThrow('comment is required when requesting changes'); + }); + + it('includes from and notify_owner flags', async () => { + const { runner, tool } = setup(); + await tool.execute({ + team: 'acme', task_id: '1', decision: 'approve', + from: 'alice', notify_owner: true, + }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--from'); + expect(args).toContain('--notify-owner'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('task not in review')); + await expect( + tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }), + ).rejects.toThrow('Failed to approve'); + }); +}); diff --git a/mcp-server/test/tools/task-attach.test.ts b/mcp-server/test/tools/task-attach.test.ts new file mode 100644 index 00000000..e8b63fe5 --- /dev/null +++ b/mcp-server/test/tools/task-attach.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-attach.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_attach', () => { + const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}'; + + function setup(response = ok(attachJson)) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_attach')! }; + } + + it('builds minimal CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf', + ]); + }); + + it('includes all optional flags', async () => { + const { runner, tool } = setup(); + await tool.execute({ + team: 'acme', task_id: '1', file: '/tmp/file.pdf', + filename: 'renamed.pdf', mime_type: 'application/pdf', + mode: 'link', from: 'alice', + }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--filename'); + expect(args).toContain('--mime-type'); + expect(args).toContain('--mode'); + expect(args).toContain('--from'); + }); + + it('returns parsed JSON', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); + expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' }); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('file too large')); + await expect( + tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }), + ).rejects.toThrow('Failed to attach file'); + }); +}); diff --git a/mcp-server/test/tools/task-briefing.test.ts b/mcp-server/test/tools/task-briefing.test.ts new file mode 100644 index 00000000..b915e994 --- /dev/null +++ b/mcp-server/test/tools/task-briefing.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-briefing.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_briefing', () => { + const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n'; + + function setup(response = ok(briefingText)) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_briefing')! }; + } + + it('builds correct CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', member: 'alice' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'briefing', '--for', 'alice', + ]); + }); + + it('returns trimmed plain text', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', member: 'alice' }); + expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('member not found')); + await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing'); + }); +}); diff --git a/mcp-server/test/tools/task-comment.test.ts b/mcp-server/test/tools/task-comment.test.ts new file mode 100644 index 00000000..2c340e48 --- /dev/null +++ b/mcp-server/test/tools/task-comment.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-comment.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_comment', () => { + function setup(response = ok('OK comment added to task #1\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_comment')! }; + } + + it('builds correct CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!', + ]); + }); + + it('includes from flag', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--from'); + expect(args[args.indexOf('--from') + 1]).toBe('alice'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('task not found')); + await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment'); + }); +}); diff --git a/mcp-server/test/tools/task-create.test.ts b/mcp-server/test/tools/task-create.test.ts new file mode 100644 index 00000000..5866e9f9 --- /dev/null +++ b/mcp-server/test/tools/task-create.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-create.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_create', () => { + function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_create')! }; + } + + it('builds minimal CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', subject: 'Fix bug' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'create', '--subject', 'Fix bug', + ]); + }); + + it('includes all optional flags', async () => { + const { runner, tool } = setup(); + await tool.execute({ + team: 'acme', + subject: 'Big task', + description: 'Details here', + owner: 'alice', + blocked_by: ['1', '2'], + related: ['3'], + status: 'pending', + active_form: 'Fixing bug', + notify: true, + from: 'bob', + }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--description'); + expect(args).toContain('--owner'); + expect(args).toContain('--blocked-by'); + expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2'); + expect(args).toContain('--related'); + expect(args[args.indexOf('--related') + 1]).toBe('3'); + expect(args).toContain('--status'); + expect(args).toContain('--activeForm'); + expect(args).toContain('--notify'); + expect(args).toContain('--from'); + }); + + it('skips empty blocked_by array', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).not.toContain('--blocked-by'); + }); + + it('returns parsed JSON', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', subject: 'Fix bug' }); + expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' }); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('team not found')); + await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task'); + }); +}); diff --git a/mcp-server/test/tools/task-get.test.ts b/mcp-server/test/tools/task-get.test.ts new file mode 100644 index 00000000..79a3d97f --- /dev/null +++ b/mcp-server/test/tools/task-get.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-get.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_get', () => { + function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_get')! }; + } + + it('builds correct CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '42' }); + expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']); + }); + + it('returns parsed JSON', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', task_id: '42' }); + expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' }); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('Task not found: #99')); + await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task'); + }); +}); diff --git a/mcp-server/test/tools/task-link.test.ts b/mcp-server/test/tools/task-link.test.ts new file mode 100644 index 00000000..7e16b802 --- /dev/null +++ b/mcp-server/test/tools/task-link.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-link.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_link', () => { + function setup(response = ok('OK task #1 blocked-by #2\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_link')! }; + } + + it('builds link CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ + team: 'acme', task_id: '1', operation: 'link', + relationship: 'blocked-by', target_id: '2', + }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'link', '1', '--blocked-by', '2', + ]); + }); + + it('builds unlink CLI args', async () => { + const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n')); + await tool.execute({ + team: 'acme', task_id: '1', operation: 'unlink', + relationship: 'related', target_id: '3', + }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'unlink', '1', '--related', '3', + ]); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('circular dependency')); + await expect( + tool.execute({ + team: 'acme', task_id: '1', operation: 'link', + relationship: 'blocked-by', target_id: '1', + }), + ).rejects.toThrow('Failed to link tasks'); + }); +}); diff --git a/mcp-server/test/tools/task-list.test.ts b/mcp-server/test/tools/task-list.test.ts new file mode 100644 index 00000000..be1a25af --- /dev/null +++ b/mcp-server/test/tools/task-list.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-list.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_list', () => { + function setup(response = ok('[{"id":"1"},{"id":"2"}]')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_list')! }; + } + + it('builds correct CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme' }); + expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']); + }); + + it('returns parsed JSON array', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme' }); + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('team dir not found')); + await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks'); + }); +}); diff --git a/mcp-server/test/tools/task-set-owner.test.ts b/mcp-server/test/tools/task-set-owner.test.ts new file mode 100644 index 00000000..d9859066 --- /dev/null +++ b/mcp-server/test/tools/task-set-owner.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-set-owner.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_set_owner', () => { + function setup(response = ok('OK task #1 owner=alice\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_set_owner')! }; + } + + it('builds args for assignment', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'set-owner', '1', 'alice', + ]); + }); + + it('builds args for clear', async () => { + const { runner, tool } = setup(ok('OK task #1 owner=cleared\n')); + await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'set-owner', '1', 'clear', + ]); + }); + + it('includes notify and from flags', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' }); + const args = runner.execute.mock.calls[0]![0] as string[]; + expect(args).toContain('--notify'); + expect(args).toContain('--from'); + expect(args[args.indexOf('--from') + 1]).toBe('bob'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('member not found')); + await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner'); + }); +}); diff --git a/mcp-server/test/tools/task-set-status.test.ts b/mcp-server/test/tools/task-set-status.test.ts new file mode 100644 index 00000000..0ca68d0d --- /dev/null +++ b/mcp-server/test/tools/task-set-status.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { register } from '../../src/tools/task-set-status.js'; +import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; + +describe('task_set_status', () => { + function setup(response = ok('OK task #1 status=completed\n')) { + const runner = createMockRunner(response); + const { server, tools } = createMockServer(); + register(server, runner); + return { runner, tool: tools.get('task_set_status')! }; + } + + it('builds correct CLI args', async () => { + const { runner, tool } = setup(); + await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); + expect(runner.execute).toHaveBeenCalledWith([ + '--team', 'acme', 'task', 'set-status', '1', 'completed', + ]); + }); + + it('returns parsed OK text', async () => { + const { tool } = setup(); + const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); + expect(result).toBe('task #1 status=completed'); + }); + + it('throws on CLI failure', async () => { + const { tool } = setup(fail('invalid transition')); + await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status'); + }); +}); diff --git a/mcp-server/test/tools/test-helpers.ts b/mcp-server/test/tools/test-helpers.ts new file mode 100644 index 00000000..fb0a8e01 --- /dev/null +++ b/mcp-server/test/tools/test-helpers.ts @@ -0,0 +1,58 @@ +import { vi } from 'vitest'; +import type { FastMCP } from 'fastmcp'; +import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js'; + +/** + * Creates a mock ITeamctlRunner that records calls and returns predetermined results. + */ +export function createMockRunner( + response: TeamctlResult | ((args: string[]) => TeamctlResult), +): ITeamctlRunner & { execute: ReturnType } { + return { + execute: vi.fn(async (args: string[]): Promise => { + return typeof response === 'function' ? response(args) : response; + }), + }; +} + +/** Success response helpers */ +export const ok = (stdout: string): TeamctlResult => ({ + stdout, + stderr: '', + exitCode: 0, +}); + +export const fail = (stderr: string): TeamctlResult => ({ + stdout: '', + stderr, + exitCode: 1, +}); + +/** + * Captures registered tools via a mock FastMCP-like server. + * Returns a map of tool name → { execute function, parameters schema }. + */ +export interface CapturedTool { + name: string; + execute: (args: Record) => Promise; + parameters: unknown; +} + +export function createMockServer(): { + server: FastMCP; + tools: Map; +} { + const tools = new Map(); + + const server = { + addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => { + tools.set(def.name, { + name: def.name, + execute: def.execute, + parameters: def.parameters, + }); + }, + } as unknown as FastMCP; + + return { server, tools }; +} diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 00000000..2bd3eb89 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/mcp-server/tsup.config.ts b/mcp-server/tsup.config.ts new file mode 100644 index 00000000..5cd234bb --- /dev/null +++ b/mcp-server/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + sourcemap: true, + dts: false, + banner: { + js: '#!/usr/bin/env node', + }, +}); diff --git a/mcp-server/vitest.config.ts b/mcp-server/vitest.config.ts new file mode 100644 index 00000000..6497809d --- /dev/null +++ b/mcp-server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 15_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 147ae6f3..80ad3475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,31 @@ importers: specifier: ^3.1.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0) + mcp-server: + dependencies: + fastmcp: + specifier: ^3.34.0 + version: 3.34.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.15.18 + version: 22.19.15 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.8.2 + version: 5.9.3 + vitest: + specifier: ^3.1.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(terser@5.46.0) + packages: 7zip-bin@5.2.0: @@ -479,6 +504,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@boundaries/elements@1.1.2': resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==} engines: {node: '>=18.18'} @@ -1063,6 +1091,12 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1203,6 +1237,16 @@ packages: '@mermaid-js/parser@1.0.0': resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1893,10 +1937,20 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1915,6 +1969,13 @@ packages: '@tanstack/virtual-core@3.13.18': resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -2078,6 +2139,9 @@ packages: '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/node@24.10.12': resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} @@ -2346,6 +2410,14 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2570,6 +2642,9 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2607,6 +2682,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -2669,6 +2748,12 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2805,6 +2890,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} @@ -2886,6 +2975,10 @@ packages: config-file-ts@0.2.8-rc1: resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -2893,19 +2986,39 @@ packages: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3148,6 +3261,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3176,6 +3292,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3184,6 +3304,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3251,6 +3375,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3301,6 +3428,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -3544,9 +3675,25 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3554,6 +3701,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.3.0: + resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3597,6 +3754,15 @@ packages: fastify@5.7.4: resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastmcp@3.34.0: + resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==} + hasBin: true + peerDependencies: + jose: ^5.0.0 + peerDependenciesMeta: + jose: + optional: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3615,10 +3781,18 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -3626,6 +3800,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} @@ -3634,6 +3812,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -3641,6 +3822,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3658,9 +3848,21 @@ packages: engines: {node: '>=18.3.0'} hasBin: true + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3709,6 +3911,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.1.0: + resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} + engines: {node: '>=10'} + gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3746,6 +3952,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -3885,6 +4095,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono@4.12.5: + resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -3898,9 +4112,17 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3925,6 +4147,13 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-readable-ids@1.0.4: + resolution: {integrity: sha512-h1zwThTims8A/SpqFGWyTx+jG1+WRMJaEeZgbtPGrIpj2AZjsOgy8Y+iNzJ0yAyN669Q6F02EK66WMWcst+2FA==} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -3942,6 +4171,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -3996,6 +4229,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} @@ -4106,6 +4343,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4118,6 +4358,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -4134,6 +4378,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -4207,6 +4455,13 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.0: + resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4234,6 +4489,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4267,6 +4525,10 @@ packages: resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==} hasBin: true + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4281,6 +4543,21 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + knuth-shuffle@1.0.8: + resolution: {integrity: sha512-IdC4Hpp+mx53zTt6VAGsAtbGM0g4BV9fP8tTcviCosSwocHcRDw9uG5Rnv6wLWckF4r72qeXFoK9NkvV1gUJCQ==} + + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-router@14.0.0: + resolution: {integrity: sha512-Ue8f/PRsLLNm6b7y+eS6xkqvsG2xH11d2VB1HPcfdfW6p5736kCHf2pXaq8q9XPQ01x0Dk7V/P5Il9pe+tGTxA==} + engines: {node: '>= 20'} + deprecated: 'Please use @koa/router instead, starting from v9! ' + + koa@3.1.2: + resolution: {integrity: sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==} + engines: {node: '>= 18'} + langium@4.2.1: resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} engines: {node: '>=20.10.0', npm: '>=10.2.3'} @@ -4328,6 +4605,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4445,6 +4726,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mcp-proxy@6.4.1: + resolution: {integrity: sha512-vzKLpJEZRqkbxz9sBqYSOWo9mJq0aGYQQLJoaMe+egYF/EA1PvcKvLx9pA1/bRpV/hfguEuB67gcs0C5KJiROQ==} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -4490,6 +4775,14 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -4589,10 +4882,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -4723,6 +5024,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -4789,6 +5094,10 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -4834,6 +5143,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4893,9 +5206,17 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -4911,6 +5232,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4922,6 +5247,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4966,10 +5294,19 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pipenet@1.4.0: + resolution: {integrity: sha512-Uc3EH2i8hnJUD0Eupj9z2jaZPjjAbooaiHGh0iFdExbE8/BDt6Lf0919Dtwx5VM83elHNWFzCOsvzsViTD5YZg==} + engines: {node: '>=22.0.0'} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5102,6 +5439,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5137,6 +5478,13 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -5144,6 +5492,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5154,6 +5506,14 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -5296,6 +5656,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -5354,6 +5718,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5425,10 +5793,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -5533,6 +5909,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -5569,6 +5949,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5580,6 +5964,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + strict-event-emitter-types@2.0.0: + resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -5644,6 +6031,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -5655,6 +6046,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -5762,6 +6157,10 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldjs@2.3.2: + resolution: {integrity: sha512-EORDwFMSZKrHPUVDhejCMDeAovRS5d8jZKiqALFiPp3cjKjEldPkxBY39ZSx3c45awz3RpKwJD1cCgGxEfy8/A==} + engines: {node: '>= 20'} + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -5781,6 +6180,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5809,6 +6216,29 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + 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 + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -5825,6 +6255,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5861,6 +6295,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5874,6 +6312,14 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5919,6 +6365,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -5931,6 +6381,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uri-templates@0.2.0: + resolution: {integrity: sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -5966,6 +6419,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -6140,6 +6597,29 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xsschema@0.4.3: + resolution: {integrity: sha512-8MFtONewz49S+R5ZfOGfJzEaPAmJ+Gw1ILjvuerYG0mOMVYe7OrsVQNlpiUTct4L3toAJAKD5NMwmCfmTIGYHQ==} + peerDependencies: + '@valibot/to-json-schema': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.16.0 + sury: ^10.0.0 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.25.0 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6163,10 +6643,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -6188,10 +6676,19 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -6354,6 +6851,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.1': {} + '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint-import-resolver-node: 0.3.9 @@ -7081,6 +7580,10 @@ snapshots: '@gar/promisify@1.1.3': {} + '@hono/node-server@1.19.11(hono@4.12.5)': + dependencies: + hono: 4.12.5 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7272,6 +7775,28 @@ snapshots: dependencies: langium: 4.2.1 + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.5) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.0(express@5.2.1) + hono: 4.12.5 + jose: 6.2.0 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7891,8 +8416,14 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -7910,6 +8441,15 @@ snapshots: '@tanstack/virtual-core@3.13.18': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@2.0.0': {} '@tybys/wasm-util@0.10.1': @@ -8113,6 +8653,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.12': dependencies: undici-types: 7.16.0 @@ -8402,6 +8946,16 @@ snapshots: abstract-logging@2.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -8720,6 +9274,14 @@ snapshots: axe-core@4.11.1: {} + axios@1.13.6(debug@4.4.3): + dependencies: + follow-redirects: 1.15.11(debug@4.4.3) + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -8750,6 +9312,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -8857,6 +9433,11 @@ snapshots: builtin-modules@3.3.0: {} + bundle-require@5.1.0(esbuild@0.27.2): + dependencies: + esbuild: 0.27.2 + load-tsconfig: 0.2.5 + bytes@3.1.2: {} cac@6.7.14: {} @@ -9030,6 +9611,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + clone-response@1.0.3: dependencies: mimic-response: 1.0.1 @@ -9102,19 +9689,37 @@ snapshots: glob: 10.5.0 typescript: 5.9.3 + consola@3.4.2: {} + console-control-strings@1.1.0: {} content-disposition@1.0.1: {} + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + core-util-is@1.0.2: optional: true core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9379,6 +9984,8 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@1.0.1: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -9407,10 +10014,14 @@ snapshots: delegates@1.0.0: {} + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -9491,6 +10102,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -9587,6 +10200,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -10019,12 +10634,73 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} + express-rate-limit@8.3.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extract-zip@2.0.1: @@ -10091,6 +10767,30 @@ snapshots: semver: 7.7.3 toad-cache: 3.7.0 + fastmcp@3.34.0: + dependencies: + '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + '@standard-schema/spec': 1.1.0 + execa: 9.6.1 + file-type: 21.3.0 + fuse.js: 7.1.0 + hono: 4.12.5 + mcp-proxy: 6.4.1 + strict-event-emitter-types: 2.0.0 + undici: 7.22.0 + uri-templates: 0.2.0 + xsschema: 0.4.3(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + yargs: 18.0.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@valibot/to-json-schema' + - arktype + - effect + - supports-color + - sury + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -10107,10 +10807,23 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + filelist@1.0.4: dependencies: minimatch: 5.1.7 @@ -10119,6 +10832,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 @@ -10130,6 +10854,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.55.1 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -10137,6 +10867,10 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10158,8 +10892,14 @@ snapshots: dependencies: fd-package-json: 2.0.0 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@0.5.2: {} + + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -10215,6 +10955,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.1.0: {} + gauge@4.0.4: dependencies: aproba: 2.1.0 @@ -10258,6 +11000,11 @@ snapshots: dependencies: pump: 3.0.3 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -10478,6 +11225,8 @@ snapshots: highlight.js@11.11.1: {} + hono@4.12.5: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -10488,8 +11237,21 @@ snapshots: html-void-elements@3.0.0: {} + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + http-cache-semantics@4.2.0: {} + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10532,6 +11294,12 @@ snapshots: transitivePeerDependencies: - supports-color + human-readable-ids@1.0.4: + dependencies: + knuth-shuffle: 1.0.8 + + human-signals@8.0.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -10548,6 +11316,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} ieee754@1.2.1: {} @@ -10588,6 +11360,8 @@ snapshots: ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} is-alphabetical@2.0.1: {} @@ -10694,6 +11468,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10707,6 +11483,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@4.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -10724,6 +11502,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -10795,6 +11575,10 @@ snapshots: jiti@2.6.1: {} + jose@6.2.0: {} + + joycon@3.1.1: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10815,6 +11599,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -10849,6 +11635,10 @@ snapshots: dependencies: commander: 8.3.0 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -10872,6 +11662,40 @@ snapshots: typescript: 5.9.3 zod: 4.3.6 + knuth-shuffle@1.0.8: {} + + koa-compose@4.1.0: {} + + koa-router@14.0.0: + dependencies: + debug: 4.4.3 + http-errors: 2.0.1 + koa-compose: 4.1.0 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + koa@3.1.2: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + langium@4.2.1: dependencies: chevrotain: 11.1.2 @@ -10930,6 +11754,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + load-tsconfig@0.2.5: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11064,6 +11890,12 @@ snapshots: math-intrinsics@1.1.0: {} + mcp-proxy@6.4.1: + dependencies: + pipenet: 1.4.0 + transitivePeerDependencies: + - supports-color + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -11217,6 +12049,10 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} mermaid@11.12.3: @@ -11440,10 +12276,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mime@3.0.0: {} @@ -11559,6 +12401,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@0.6.4: {} negotiator@1.0.0: {} @@ -11634,6 +12478,11 @@ snapshots: normalize-url@6.1.0: {} + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + npmlog@6.0.2: dependencies: are-we-there-yet: 3.0.1 @@ -11687,6 +12536,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11783,10 +12636,14 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -11795,6 +12652,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -11807,6 +12666,8 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -11845,8 +12706,23 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + pipenet@1.4.0: + dependencies: + axios: 1.13.6(debug@4.4.3) + debug: 4.4.3 + human-readable-ids: 1.0.4 + koa: 3.1.2 + koa-router: 14.0.0 + pump: 3.0.3 + tldjs: 2.3.2 + yargs: 18.0.0 + transitivePeerDependencies: + - supports-color + pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -11889,6 +12765,15 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.2 + postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -11920,6 +12805,10 @@ snapshots: prettier@3.8.1: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + proc-log@5.0.0: {} process-nextick-args@2.0.1: {} @@ -11945,6 +12834,13 @@ snapshots: property-information@7.1.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -11952,12 +12848,25 @@ snapshots: punycode@2.3.1: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -12155,6 +13064,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.11: @@ -12245,6 +13156,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -12313,11 +13234,36 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + 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 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -12448,6 +13394,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} split2@4.2.0: {} @@ -12479,6 +13427,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@1.5.0: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -12488,6 +13438,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + strict-event-emitter-types@2.0.0: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -12586,6 +13538,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -12594,6 +13548,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + style-mod@4.1.3: {} style-to-js@1.1.21: @@ -12737,6 +13695,10 @@ snapshots: tinyspy@4.0.4: {} + tldjs@2.3.2: + dependencies: + punycode: 2.3.1 + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -12751,6 +13713,14 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tree-kill@1.2.2: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -12776,6 +13746,36 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.55.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -12792,6 +13792,12 @@ snapshots: type-fest@0.13.1: optional: true + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -12843,6 +13849,8 @@ snapshots: uglify-js@3.19.3: optional: true + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -12856,6 +13864,10 @@ snapshots: undici-types@7.16.0: {} + undici@7.22.0: {} + + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -12914,6 +13926,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -12948,6 +13962,8 @@ snapshots: dependencies: punycode: 2.3.1 + uri-templates@0.2.0: {} + use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): dependencies: react: 18.3.1 @@ -12973,6 +13989,8 @@ snapshots: uuid@11.1.0: {} + vary@1.1.2: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -12995,6 +14013,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.15)(terser@5.46.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@22.19.15)(terser@5.46.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@25.0.7)(terser@5.46.0): dependencies: cac: 6.7.14 @@ -13013,6 +14049,16 @@ snapshots: - supports-color - terser + vite@5.4.21(@types/node@22.19.15)(terser@5.46.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.55.1 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + terser: 5.46.0 + vite@5.4.21(@types/node@25.0.7)(terser@5.46.0): dependencies: esbuild: 0.21.5 @@ -13023,6 +14069,46 @@ snapshots: fsevents: 2.3.3 terser: 5.46.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(terser@5.46.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(terser@5.46.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@22.19.15)(terser@5.46.0) + vite-node: 3.2.4(@types/node@22.19.15)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.15 + happy-dom: 20.0.2 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0): dependencies: '@types/chai': 5.2.3 @@ -13176,6 +14262,11 @@ snapshots: xmlbuilder@15.1.1: {} + xsschema@0.4.3(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6): + optionalDependencies: + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13188,6 +14279,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -13198,6 +14291,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 @@ -13213,12 +14315,18 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5739b74..0caafff1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +packages: + - mcp-server ignoredBuiltDependencies: - esbuild diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index e99caa7c..cea9d76e 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -198,7 +198,8 @@ async function handleGetSessionsByIds( async function handleGetSessionDetail( _event: IpcMainInvokeEvent, projectId: string, - sessionId: string + sessionId: string, + options?: { bypassCache?: boolean } ): Promise { try { const validatedProject = validateProjectId(projectId); @@ -220,7 +221,7 @@ async function handleGetSessionDetail( // Check cache first let sessionDetail = dataCache.get(cacheKey); - if (sessionDetail) { + if (sessionDetail && !options?.bypassCache) { return sessionDetail; } diff --git a/src/main/ipc/subagents.ts b/src/main/ipc/subagents.ts index 747605d3..9dc10d52 100644 --- a/src/main/ipc/subagents.ts +++ b/src/main/ipc/subagents.ts @@ -56,7 +56,8 @@ async function handleGetSubagentDetail( _event: IpcMainInvokeEvent, projectId: string, sessionId: string, - subagentId: string + subagentId: string, + options?: { bypassCache?: boolean } ): Promise { try { const validatedProject = validateProjectId(projectId); @@ -85,7 +86,7 @@ async function handleGetSubagentDetail( // Check cache first let subagentDetail = dataCache.getSubagent(cacheKey); - if (subagentDetail) { + if (subagentDetail && !options?.bypassCache) { return subagentDetail; } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index edc4da79..85f36da7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -402,11 +402,13 @@ async function handleGetData( } const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const leadSessionTextFingerprints = new Set(); + + // Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages) + // so we can dedup lead_process live messages against them. + const existingTextFingerprints = new Set(); for (const msg of data.messages) { - if ((msg as { source?: unknown }).source !== 'lead_session') continue; if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); } const keyFor = (m: { @@ -421,14 +423,24 @@ async function handleGetData( return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; }; + // Text-based fingerprints for lead_process messages to catch duplicates + // with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text) + const leadProcessTextFingerprints = new Set(); + const merged: typeof data.messages = []; const seen = new Set(); for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process') { + if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; - if (leadSessionTextFingerprints.has(fp)) { + // Skip if same text already exists from any source (inbox, lead_session, etc.) + if (existingTextFingerprints.has(fp)) { continue; } + // Dedup lead_process messages with same text but different messageIds + if (leadProcessTextFingerprints.has(fp)) { + continue; + } + leadProcessTextFingerprints.add(fp); } const key = keyFor(msg); if (seen.has(key)) continue; diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index 6443436b..96ce9994 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -9,8 +9,18 @@ import type { FileLineStats, MemberFullStats } from '@shared/types'; const logger = createLogger('Service:MemberStatsComputer'); -function isValidFilePath(value: string): boolean { - return value.length > 0 && value !== 'null' && value !== 'undefined' && value !== 'None'; +const TRAILING_PUNCT_CHARS = new Set([';', '.', ',']); +const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']); + +function stripTrailingPunct(s: string): string { + let end = s.length; + while (end > 0 && TRAILING_PUNCT_CHARS.has(s[end - 1])) end--; + return end === s.length ? s : s.slice(0, end); +} + +export function isValidFilePath(value: string): boolean { + const cleaned = stripTrailingPunct(value.trim()); + return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/'); } const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -73,11 +83,19 @@ export class MemberStatsComputer { .filter(isValidFilePath) .sort((a, b) => a.localeCompare(b)); + // Also filter perFileStats keys to exclude invalid paths + const filteredFileStats: Record = {}; + for (const [fp, fls] of Object.entries(perFileStats)) { + if (isValidFilePath(fp)) { + filteredFileStats[fp] = fls; + } + } + const stats: MemberFullStats = { linesAdded, linesRemoved, filesTouched: validFiles, - fileStats: perFileStats, + fileStats: filteredFileStats, toolUsage, inputTokens, outputTokens, @@ -121,18 +139,24 @@ export class MemberStatsComputer { // Track last known content per file for accurate Write/NotebookEdit diffs const fileLastContent = new Map(); + const cleanPath = (fp: string): string => stripTrailingPunct(fp.trim()); + const trackFile = (fp: string): void => { - if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp); + if (typeof fp === 'string') { + const cleaned = cleanPath(fp); + if (isValidFilePath(cleaned)) filesTouchedSet.add(cleaned); + } }; const addFileLines = (fp: string, added: number, removed: number): void => { - if (!isValidFilePath(fp)) return; - const existing = perFileStats[fp]; + const cleaned = cleanPath(fp); + if (!isValidFilePath(cleaned)) return; + const existing = perFileStats[cleaned]; if (existing) { existing.added += added; existing.removed += removed; } else { - perFileStats[fp] = { added, removed }; + perFileStats[cleaned] = { added, removed }; } }; diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 7868cc1c..a2f7fc04 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) { summary, messageId, }; + if (flags.source) payload.source = flags.source; var lastErr; for (var attempt = 0; attempt < 8; attempt++) { @@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) { text: inboxText, summary: 'Approved #' + String(taskId), from, + source: 'system_notification', }); } @@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { text: inboxText, summary: 'Fix request for #' + String(taskId), from, + source: 'system_notification', }); } @@ -1282,6 +1285,7 @@ async function main() { text: parts.join('\n'), summary: 'New task #' + String(task.id) + ' assigned', from, + source: 'system_notification', }); } } @@ -1319,6 +1323,7 @@ async function main() { text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''), summary: 'Comment on #' + String(result.taskId), from: from, + source: 'system_notification', }); } catch (e) { /* best-effort */ } } @@ -1396,6 +1401,7 @@ async function main() { text: parts.join('\n'), summary: 'Task #' + String(task.id) + ' assigned', from, + source: 'system_notification', }); } return; @@ -1493,7 +1499,10 @@ async function main() { if (domain === 'message') { if (action === 'send') { - const result = sendInboxMessage(paths, teamName, args.flags); + // Strip source from agent flags — only internal callers may set it + var msgFlags = Object.assign({}, args.flags); + delete msgFlags.source; + const result = sendInboxMessage(paths, teamName, msgFlags); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ec3c8a43..5b4d3ede 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -9,10 +9,15 @@ import { } from '@main/utils/pathDecoder'; import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; +import { + AGENT_BLOCK_CLOSE, + AGENT_BLOCK_OPEN, + stripAgentBlocks, +} from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; +import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -54,13 +59,14 @@ import type { TeamTask, TeamTaskStatus, TeamTaskWithKanban, + ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; const logger = createLogger('Service:TeamDataService'); const MIN_TEXT_LENGTH = 30; -const MAX_LEAD_TEXTS = 50; +const MAX_LEAD_TEXTS = 150; const PROCESS_HEALTH_INTERVAL_MS = 2_000; const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024; const TASK_MAP_YIELD_EVERY = 250; @@ -283,6 +289,7 @@ export class TeamDataService { // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. + // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. if (leadTexts.length > 0 && sentMessages.length > 0) { const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const leadSessionFingerprints = new Set(); @@ -292,17 +299,67 @@ export class TeamDataService { } messages = messages.filter((m) => { if (m.source !== 'lead_process') return true; + // Captured SendMessage messages (with recipient) are real messages — never dedup + if (m.to) return true; const fp = `${m.from}\0${normalizeText(m.text ?? '')}`; return !leadSessionFingerprints.has(fp); }); } - // Enrich messages without leadSessionId: assign current session for lead_process/user_sent. - // lead_process messages surviving dedup are from the current session; - // user_sent messages written before this feature lack the field. - if (config.leadSessionId) { - for (const msg of messages) { - if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) { + // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's + // session ID (by timestamp). This avoids the old forward-only propagation bug where + // messages between two sessions always inherited the *earlier* session, causing a + // spurious "New session" divider even when the message is chronologically closer to + // the later session. + if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + // Collect indices of messages that already have a leadSessionId (anchors). + const anchors: { index: number; time: number; sessionId: string }[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i].leadSessionId) { + anchors.push({ + index: i, + time: Date.parse(messages[i].timestamp), + sessionId: messages[i].leadSessionId!, + }); + } + } + + if (anchors.length > 0) { + // For each message without leadSessionId, find the closest anchor by timestamp + // and inherit its sessionId. + let anchorIdx = 0; + for (let i = 0; i < messages.length; i++) { + if (messages[i].leadSessionId) { + // Advance anchorIdx to track current position for efficient lookup + while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { + anchorIdx++; + } + continue; + } + + const msgTime = Date.parse(messages[i].timestamp); + + // Find closest anchor by timestamp (binary-search-like scan from current position) + let bestAnchor = anchors[0]; + let bestDist = Math.abs(msgTime - bestAnchor.time); + for (const anchor of anchors) { + const dist = Math.abs(msgTime - anchor.time); + if (dist < bestDist) { + bestDist = dist; + bestAnchor = anchor; + } else if (dist > bestDist && anchor.time > msgTime) { + // Anchors are sorted by index (asc time) — once distance grows past the + // message time, further anchors will only be farther. + break; + } + } + messages[i].leadSessionId = bestAnchor.sessionId; + } + } else if (config.leadSessionId) { + // No anchors at all — fall back to config.leadSessionId for everything. + for (const msg of messages) { msg.leadSessionId = config.leadSessionId; } } @@ -1103,7 +1160,19 @@ export class TeamDataService { } async sendMessage(teamName: string, request: SendMessageRequest): Promise { - return this.inboxWriter.sendMessage(teamName, request); + // Enrich with leadSessionId so session boundary separators work + let enrichedRequest = request; + if (!enrichedRequest.leadSessionId) { + try { + const config = await this.configReader.getConfig(teamName); + if (config?.leadSessionId) { + enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId }; + } + } catch { + // non-critical + } + } + return this.inboxWriter.sendMessage(teamName, enrichedRequest); } private resolveLeadNameFromConfig(config: TeamConfig | null): string { @@ -1357,13 +1426,29 @@ export class TeamDataService { const projectId = encodePath(config.projectPath); const baseDir = extractBaseDir(projectId); - const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`); + let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`); try { await fs.promises.access(jsonlPath, fs.constants.F_OK); } catch { - logger.debug(`Lead session JSONL not found: ${jsonlPath}`); - return []; + // Claude Code encodes underscores as hyphens in project directory names; + // our encodePath only handles slashes. Try the underscore-to-hyphen variant. + const altBaseDir = baseDir.replace(/_/g, '-'); + if (altBaseDir !== baseDir) { + const altPath = path.join( + getProjectsBasePath(), + altBaseDir, + `${config.leadSessionId}.jsonl` + ); + try { + await fs.promises.access(altPath, fs.constants.F_OK); + jsonlPath = altPath; + } catch { + return []; + } + } else { + return []; + } } const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead'; @@ -1374,6 +1459,7 @@ export class TeamDataService { const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB const textsReversed: InboxMessage[] = []; + const seenMessageIds = new Set(); const handle = await fs.promises.open(jsonlPath, 'r'); try { const stat = await handle.stat(); @@ -1410,20 +1496,69 @@ export class TeamDataService { const timestamp = typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString(); + const textParts: string[] = []; for (const block of content as Record[]) { if (block.type !== 'text' || typeof block.text !== 'string') continue; - const text = block.text.trim(); - if (text.length < MIN_TEXT_LENGTH) continue; - textsReversed.push({ - from: leadName, - text, - timestamp, - read: true, - source: 'lead_session', - leadSessionId: config.leadSessionId, - }); - if (textsReversed.length >= MAX_LEAD_TEXTS) break; + textParts.push(block.text); } + if (textParts.length === 0) continue; + + const combined = stripAgentBlocks(textParts.join('\n')).trim(); + if (combined.length < MIN_TEXT_LENGTH) continue; + + // Collect tool_use details from following lines (text and tool_use are separate in JSONL). + // tool_result (type=user) lines are interleaved between tool_use lines — skip them. + const toolCallsList: ToolCallMeta[] = []; + const lookaheadLimit = Math.min(i + 200, lines.length); + for (let j = i + 1; j < lookaheadLimit; j++) { + const tLine = lines[j]?.trim(); + if (!tLine) continue; + let tMsg: Record; + try { + tMsg = JSON.parse(tLine) as Record; + } catch { + continue; + } + if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines + const tMessage = (tMsg.message ?? tMsg) as Record; + const tContent = tMessage.content; + if (!Array.isArray(tContent)) continue; + const tBlocks = tContent as Record[]; + if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop + for (const b of tBlocks) { + if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') { + const input = (b.input ?? {}) as Record; + toolCallsList.push({ + name: b.name, + preview: extractToolPreview(b.name, input), + }); + } + } + } + const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined; + const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; + + // Stable messageId: timestamp + text prefix (survives tail-scan range changes) + const textPrefix = combined + .slice(0, 50) + .replace(/[^\p{L}\p{N}]/gu, '') + .slice(0, 20); + + const messageId = `lead-session-${timestamp}-${textPrefix}`; + if (seenMessageIds.has(messageId)) continue; + seenMessageIds.add(messageId); + + textsReversed.push({ + from: leadName, + text: combined, + timestamp, + read: true, + source: 'lead_session', + leadSessionId: config.leadSessionId, + messageId, + toolSummary, + toolCalls, + }); if (textsReversed.length >= MAX_LEAD_TEXTS) break; } diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index b4e9cc5e..0e9a8849 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -99,6 +99,8 @@ export class TeamInboxReader { summary: typeof row.summary === 'string' ? row.summary : undefined, color: typeof row.color === 'string' ? row.color : undefined, messageId: typeof row.messageId === 'string' ? row.messageId : undefined, + source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, + leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, }); } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index a0776db4..8ac414ec 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -30,6 +30,7 @@ export class TeamInboxWriter { messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, ...(request.source && { source: request.source }), + ...(request.leadSessionId && { leadSessionId: request.leadSessionId }), }; await withInboxLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2376468d..e80a3342 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -22,6 +22,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; +import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; @@ -49,6 +50,7 @@ import type { TeamProvisioningProgress, TeamProvisioningState, TeamTask, + ToolCallMeta, } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); @@ -60,7 +62,7 @@ const STDOUT_RING_LIMIT = 64 * 1024; const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const SHELL_ENV_TIMEOUT_MS = 12000; -const PROBE_CACHE_TTL_MS = 10 * 60_000; +const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_TIMEOUT_MS = 60000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; @@ -79,8 +81,6 @@ const PREFLIGHT_PING_ARGS = [ 'haiku', '--max-turns', '1', - '--max-budget-usd', - '0.05', '--no-session-persistence', ] as const; const PREFLIGHT_EXPECTED = 'PONG'; @@ -163,15 +163,10 @@ interface ProvisioningRun { rejectOnce: (error: string) => void; timeoutHandle: NodeJS.Timeout; } | null; - /** - * Accumulates assistant text for direct user→lead messages (no relay capture active). - * Flushed to liveLeadProcessMessages on result.success. - */ - directReplyParts: string[]; - /** Monotonic counter for stream-json turns (incremented on result). */ - leadTurnSeq: number; - /** Stable timestamp used for the current aggregated lead turn message. */ - leadTurnMessageTimestamp: string | null; + /** Monotonic counter for individual lead assistant messages. */ + leadMsgSeq: number; + /** Accumulated tool_use details between text messages. */ + pendingToolCalls: ToolCallMeta[]; /** Throttle timestamp for emitting inbox refresh events for lead text. */ lastLeadTextEmitMs: number; /** @@ -1752,9 +1747,8 @@ export class TeamProvisioningService { isLaunch: false, fsPhase: 'waiting_config', leadRelayCapture: null, - directReplyParts: [], - leadTurnSeq: 0, - leadTurnMessageTimestamp: null, + leadMsgSeq: 0, + pendingToolCalls: [], lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2053,9 +2047,8 @@ export class TeamProvisioningService { isLaunch: true, fsPhase: 'waiting_members', leadRelayCapture: null, - directReplyParts: [], - leadTurnSeq: 0, - leadTurnMessageTimestamp: null, + leadMsgSeq: 0, + pendingToolCalls: [], lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2493,8 +2486,6 @@ export class TeamProvisioningService { }, }; run.leadRelayCapture = capture; - // Clear any direct reply parts — relay capture takes priority - run.directReplyParts = []; }); try { @@ -2797,6 +2788,16 @@ export class TeamProvisioningService { } pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { + // Enrich with leadSessionId if missing — needed for session boundary separators + if (!message.leadSessionId) { + const runId = this.activeByTeam.get(teamName); + if (runId) { + const run = this.runs.get(runId); + if (run?.detectedSessionId) { + message.leadSessionId = run.detectedSessionId; + } + } + } const MAX = 100; const list = this.liveLeadProcessMessages.get(teamName) ?? []; const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; @@ -2876,7 +2877,7 @@ export class TeamProvisioningService { const hasSendMessageToUser = (content ?? []).some((part) => { if (!part || typeof part !== 'object') return false; if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; - const input = (part as Record).input; + const input = part.input; if (!input || typeof input !== 'object') return false; return (input as Record).recipient === 'user'; }); @@ -2885,7 +2886,7 @@ export class TeamProvisioningService { .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) { - const text = textParts.join(''); + const text = textParts.join('\n'); // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); @@ -2908,34 +2909,37 @@ export class TeamProvisioningService { clearTimeout(capture.idleHandle); } capture.idleHandle = setTimeout(() => { - const combined = capture.textParts.join('').trim(); + const combined = capture.textParts.join('\n').trim(); capture.resolveOnce(combined); }, capture.idleMs); } } else if (run.provisioningComplete) { - // Accumulate assistant text for a single "live lead turn" message in Messages. - // If the same assistant message includes SendMessage(to:"user"), prefer the captured - // SendMessage and avoid duplicating it as a separate lead text entry. + // Push each assistant text block as a separate live message (per-message pattern). + // When the same assistant message includes SendMessage(to:"user"), skip text — + // captureSendMessageToUser() handles it separately. if (!run.silentUserDmForward && !hasSendMessageToUser) { - run.directReplyParts.push(text); - const raw = run.directReplyParts.join(''); - const cleanText = stripAgentBlocks(raw).trim(); - if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) { + const cleanText = stripAgentBlocks(text).trim(); + if (cleanText.length > 0) { + run.leadMsgSeq += 1; const leadName = run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - if (!run.leadTurnMessageTimestamp) { - run.leadTurnMessageTimestamp = nowIso(); - } - const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; + const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`; + // Attach accumulated tool call details from preceding tool_use messages, then reset. + const toolCalls = + run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; + const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; + run.pendingToolCalls = []; const leadMsg: InboxMessage = { from: leadName, text: cleanText, - timestamp: run.leadTurnMessageTimestamp, + timestamp: nowIso(), read: true, summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, messageId, source: 'lead_process', + toolSummary, + toolCalls, }; this.pushLiveLeadProcessMessage(run.teamName, leadMsg); @@ -2952,13 +2956,24 @@ export class TeamProvisioningService { }); } } - } else if (hasSendMessageToUser) { - run.directReplyParts = []; - run.leadTurnMessageTimestamp = null; - this.removeLiveLeadProcessMessage( - run.teamName, - `lead-turn-${run.runId}-${run.leadTurnSeq}` - ); + } + } + } + + // Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json). + // These details will be attached to the next text message as toolCalls/toolSummary. + if (run.provisioningComplete) { + for (const block of content ?? []) { + if ( + block?.type === 'tool_use' && + typeof block.name === 'string' && + block.name !== 'SendMessage' + ) { + const input = (block.input ?? {}) as Record; + run.pendingToolCalls.push({ + name: block.name, + preview: extractToolPreview(block.name, input), + }); } } } @@ -3098,49 +3113,8 @@ export class TeamProvisioningService { } if (run.leadRelayCapture) { const capture = run.leadRelayCapture; - const combined = capture.textParts.join('').trim(); + const combined = capture.textParts.join('\n').trim(); capture.resolveOnce(combined); - } else if (run.provisioningComplete && run.directReplyParts.length > 0) { - // Finalize the current live lead turn message (single messageId per turn). - const rawReply = run.directReplyParts.join('').trim(); - run.directReplyParts = []; - const leadName = - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || - 'team-lead'; - const replyText = stripAgentBlocks(rawReply).trim(); - const finalText = - replyText.length > 0 - ? replyText - : rawReply.length > 0 - ? '(Message received and processed)' - : ''; - if (finalText.length > 0) { - if (!run.leadTurnMessageTimestamp) { - run.leadTurnMessageTimestamp = nowIso(); - } - const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; - const replyMsg: InboxMessage = { - from: leadName, - text: finalText, - timestamp: run.leadTurnMessageTimestamp, - read: true, - summary: finalText.length > 60 ? finalText.slice(0, 57) + '...' : finalText, - messageId, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(run.teamName, replyMsg); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-turn-final', - }); - } - } - // Turn boundary: advance lead turn sequence. - if (run.provisioningComplete) { - run.leadTurnSeq += 1; - run.leadTurnMessageTimestamp = null; - run.directReplyParts = []; } // Clear silent relay flag after any successful turn. run.silentUserDmForward = null; @@ -3158,12 +3132,6 @@ export class TeamProvisioningService { if (run.leadRelayCapture) { run.leadRelayCapture.rejectOnce(errorMsg); } - // Turn boundary: advance lead turn sequence. - if (run.provisioningComplete) { - run.leadTurnSeq += 1; - run.leadTurnMessageTimestamp = null; - run.directReplyParts = []; - } // Clear silent relay flag after any errored turn. run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 88ef5b9e..6e7e5bd4 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -76,6 +76,20 @@ export class TeamSentMessagesStore { attachments: Array.isArray(row.attachments) ? row.attachments : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, + toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined, + toolCalls: Array.isArray(row.toolCalls) + ? (row.toolCalls as unknown[]) + .filter( + (tc): tc is { name: string; preview?: string } => + tc != null && + typeof tc === 'object' && + typeof (tc as Record).name === 'string' + ) + .map((tc) => ({ + name: tc.name, + preview: typeof tc.preview === 'string' ? tc.preview : undefined, + })) + : undefined, }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 687132ad..9f361333 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -347,14 +347,18 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('search-sessions', projectId, query, maxResults), searchAllProjects: (query: string, maxResults?: number) => ipcRenderer.invoke('search-all-projects', query, maxResults), - getSessionDetail: (projectId: string, sessionId: string) => - ipcRenderer.invoke('get-session-detail', projectId, sessionId), + getSessionDetail: (projectId: string, sessionId: string, options?: { bypassCache?: boolean }) => + ipcRenderer.invoke('get-session-detail', projectId, sessionId, options), getSessionMetrics: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-metrics', projectId, sessionId), getWaterfallData: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), - getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => - ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), + getSubagentDetail: ( + projectId: string, + sessionId: string, + subagentId: string, + options?: { bypassCache?: boolean } + ) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId, options), getSessionGroups: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-groups', projectId, sessionId), getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 7fc64c7c..033bebe5 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -246,7 +246,11 @@ export class HttpAPIClient implements ElectronAPI { return this.get(`/api/search?${params}`); }; - getSessionDetail = (projectId: string, sessionId: string): Promise => + getSessionDetail = ( + projectId: string, + sessionId: string, + _options?: { bypassCache?: boolean } + ): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` ); @@ -264,7 +268,8 @@ export class HttpAPIClient implements ElectronAPI { getSubagentDetail = ( projectId: string, sessionId: string, - subagentId: string + subagentId: string, + _options?: { bypassCache?: boolean } ): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 243a635e..20a6f69a 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -16,6 +16,8 @@ const SCROLL_THRESHOLD = 300; /** Must match the `w-80` (320px) context panel width used in the layout below. */ const CONTEXT_PANEL_WIDTH_PX = 320; +import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; + import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; @@ -190,6 +192,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]); + const visibleContextPercentLabel = useMemo(() => { + const visibleTokens = sumContextInjectionTokens(allContextInjections); + return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens); + }, [allContextInjections, lastAiGroupTotalTokens]); + // State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel) const [isNavigationHighlight, setIsNavigationHighlight] = useState(false); const navigationHighlightTimerRef = useRef | null>(null); @@ -828,7 +835,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { : 'var(--color-text-secondary)', }} > - Context ({allContextInjections.length}) + {visibleContextPercentLabel ?? `Context (${allContextInjections.length})`} )} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index bf366be6..499849f4 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -9,6 +9,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; +import { nameColorSet } from '@renderer/utils/projectColor'; import { Activity, Bell, @@ -67,12 +68,17 @@ export const SortableTab = ({ ) ); - const teamColor = useStore((s) => { + const teamColorSet = useStore((s) => { if (tab.type !== 'team' || !tab.teamName) return null; const team = s.teamByName[tab.teamName]; - return team?.color ?? null; + const explicitColor = + team?.color ?? + (s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined); + if (explicitColor) return getTeamColorSet(explicitColor); + // Fallback: deterministic color derived from display name + const displayName = team?.displayName ?? tab.label; + return nameColorSet(displayName); }); - const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, @@ -96,8 +102,15 @@ export const SortableTab = ({ ? teamColorSet ? teamColorSet.badge : 'var(--color-surface-overlay)' - : 'transparent', - color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)', + : teamColorSet + ? teamColorSet.badge + : 'transparent', + color: + isActive || isHovered + ? 'var(--color-text)' + : teamColorSet + ? teamColorSet.text + : 'var(--color-text-muted)', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outlineOffset: '-1px', borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined, @@ -125,7 +138,7 @@ export const SortableTab = ({ role="tab" tabIndex={0} aria-selected={isActive} - className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5" + className="group flex shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5" style={style} onClick={(e) => onTabClick(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)} @@ -150,7 +163,11 @@ export const SortableTab = ({ )} - {tab.label} + 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`} + > + {tab.label} + {isTeamTab && ( { return (
{ }} > - {tab.label} + 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`} + > + {tab.label} +
); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 74b5f215..5b59053c 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -352,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { Gives users a reliable window-drag target regardless of how many tabs are open. Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
@@ -404,7 +405,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J cliLogsTail={filteredText} order="newest-first" searchQueryOverride={searchQuery.trim() ? searchQuery : undefined} - className="max-h-[320px] p-2" + className="max-h-[213px] p-2" containerRefCallback={(el) => { logContainerRef.current = el; }} diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 971faecd..986a763b 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react'; import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; +type CliLogsOrder = 'oldest-first' | 'newest-first'; + interface CliLogsRichViewProps { cliLogsTail: string; - order?: 'oldest-first' | 'newest-first'; + order?: CliLogsOrder; onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; containerRefCallback?: (el: HTMLDivElement | null) => void; /** Optional local search query override for inline highlighting */ @@ -108,7 +110,7 @@ const StreamGroup = ({
{isExpanded && ( -
+
{ const scrollRef = useRef(null); + const stickToEdgeRef = useRef(true); + const lastOrderRef = useRef(order); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); @@ -168,15 +177,38 @@ export const CliLogsRichView = ({ return expanded; }, [groups, collapsedGroupIds]); - // Auto-scroll to bottom on new content - useEffect(() => { - if (scrollRef.current) { + const computeShouldStickToEdge = useCallback( + (el: HTMLDivElement): boolean => { + // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge + const thresholdPx = 16; if (order === 'newest-first') { - scrollRef.current.scrollTop = 0; - } else { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + return el.scrollTop <= thresholdPx; } + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + return distanceFromBottom <= thresholdPx; + }, + [order] + ); + + // Auto-scroll only when user is pinned to the edge. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + // If the sort order changes, always snap once (expectation: show the "newest edge"). + if (lastOrderRef.current !== order) { + lastOrderRef.current = order; + stickToEdgeRef.current = true; } + + if (!stickToEdgeRef.current) return; + + if (order === 'newest-first') { + el.scrollTop = 0; + return; + } + + el.scrollTop = el.scrollHeight; }, [cliLogsTail, order]); const handleGroupToggle = useCallback((groupId: string) => { @@ -218,7 +250,12 @@ export const CliLogsRichView = ({ )} onScroll={(e) => { const el = e.currentTarget; - onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + stickToEdgeRef.current = computeShouldStickToEdge(el); + onScroll?.({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }); }} > {hasContent ? ( @@ -242,10 +279,15 @@ export const CliLogsRichView = ({ scrollRef.current = el; containerRefCallback?.(el); }} - className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)} + className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} onScroll={(e) => { const el = e.currentTarget; - onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + stickToEdgeRef.current = computeShouldStickToEdge(el); + onScroll?.({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }); }} > {visibleGroups.map((group) => diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 583ba823..5b513f4d 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -19,6 +19,8 @@ interface CollapsibleTeamSectionProps { badge?: string | number; /** Secondary badge (e.g. unread count). Shown next to main badge when defined. */ secondaryBadge?: number; + /** Element rendered immediately after secondary badge (e.g. mark-all-read button). */ + afterBadge?: React.ReactNode; /** Extra element rendered inline after badges (e.g. notification icon). */ headerExtra?: React.ReactNode; defaultOpen?: boolean; @@ -40,6 +42,7 @@ export const CollapsibleTeamSection = ({ icon, badge, secondaryBadge, + afterBadge, headerExtra, defaultOpen = true, forceOpen, @@ -109,6 +112,7 @@ export const CollapsibleTeamSection = ({ {secondaryBadge} new )} + {afterBadge} {headerExtra}
{action &&
{action}
} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7b6cb95d..f80056e7 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -456,16 +456,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele // Keep lead-session context fresh in the background while the team tab is active. // This keeps the button value current even when the panel is closed. + // For offline teams: fetch once on mount so the percentage shows immediately. + // For alive teams: fetch on mount + periodic refresh every 30s. useEffect(() => { if (!isThisTabActive) return; if (!tabId || !projectId || !leadSessionId) return; + + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); + if (!data?.isAlive) return; - const tick = (): void => { + const id = window.setInterval(() => { void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); - }; - tick(); - const id = window.setInterval(tick, 30_000); + }, 30_000); return () => window.clearInterval(id); }, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]); @@ -574,9 +577,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return result; }, [data, timeWindow, kanbanFilter.selectedOwners]); + const activeMembers = useMemo( + () => (data?.members ?? []).filter((m) => !m.removedAt), + [data?.members] + ); + + const leadMemberName = useMemo( + () => activeMembers.find((m) => m.agentType === 'team-lead')?.name, + [activeMembers] + ); + const filteredMessages = useMemo(() => { if (!data) return []; let list = data.messages; + // Temporarily hide lead→user messages from the UI + // (notifications and other processing still receive them via data.messages) + if (leadMemberName) { + list = list.filter((m) => !(m.to?.trim() === 'user' && m.from?.trim() === leadMemberName)); + } if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); @@ -603,7 +621,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }); } return list; - }, [data, timeWindow, messagesFilter, messagesSearchQuery]); + }, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]); const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); const messagesUnreadCount = useMemo( @@ -627,11 +645,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return filterKanbanTasks(filteredTasks, query); }, [filteredTasks, kanbanSearch]); - const activeMembers = useMemo( - () => (data?.members ?? []).filter((m) => !m.removedAt), - [data?.members] - ); - const activeTeammateCount = useMemo( () => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length, [activeMembers] @@ -1415,44 +1428,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele ? messagesUnreadCount : undefined } - headerExtra={ - <> + afterBadge={ + messagesUnreadCount > 0 ? ( - + + - Desktop notifications plugin + Mark all as read - {messagesUnreadCount > 0 && ( - - - - - Mark all as read - - )} - + ) : undefined + } + headerExtra={ + + + + + Desktop notifications plugin + } defaultOpen action={ @@ -1649,7 +1662,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele currentName={data.config.name} currentDescription={data.config.description ?? ''} currentColor={data.config.color ?? ''} - currentMembers={data.members} + currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} onSaved={() => void selectTeam(teamName)} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 7524b49d..9633d7af 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -561,9 +561,7 @@ export const TeamListView = (): React.JSX.Element => { void fetchTeams(); }} > - {teamsLoading ? ( - - ) : null} + {teamsLoading ? : null} Refresh
@@ -671,7 +669,7 @@ export const TeamListView = (): React.JSX.Element => { key={team.teamName} role="button" tabIndex={0} - className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${ + className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${ matchesCurrentProject ? 'border-emerald-500/70 ring-1 ring-emerald-500/30' : 'border-[var(--color-border)]' @@ -695,7 +693,11 @@ export const TeamListView = (): React.JSX.Element => { style={{ backgroundColor: teamColorSet.badge }} /> ) : null} -
+

@@ -779,6 +781,8 @@ export const TeamListView = (): React.JSX.Element => { Members: {team.memberCount} )} +

+
{(() => { const tc = taskCountsByTeam.get(team.teamName); const pending = tc?.pending ?? 0; @@ -831,8 +835,8 @@ export const TeamListView = (): React.JSX.Element => {
); })()} + {renderTeamRecentPaths(team, status)}
- {renderTeamRecentPaths(team, status)}
); diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index cb8a7331..136f3f86 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -3,10 +3,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; -import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; +import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; -import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { TimelineItem } from './LeadThoughtsGroup'; +import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; interface ActivityTimelineProps { messages: InboxMessage[]; @@ -134,11 +134,6 @@ export const ActivityTimeline = ({ const isInitializedRef = useRef(false); const prevVisibleCountRef = useRef(visibleCount); - // Track whether the user was seeing ALL messages (no hidden ones). - // If so, auto-expand when new messages push count past the limit, - // so previously visible messages don't silently disappear. - const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE); - const colorMap = members ? buildMemberColorMap(members) : new Map(); const memberInfo = new Map(); if (members) { @@ -169,22 +164,33 @@ export const ActivityTimeline = ({ if (member) onMemberClick?.(member); }; - const hiddenCount = Math.max(0, messages.length - visibleCount); + // Pagination counts only significant (non-thought) messages so that lead thoughts + // don't consume the page limit — they collapse into a single visual group anyway. + const { visibleMessages, hiddenCount } = useMemo(() => { + const total = messages.length; + if (total === 0) return { visibleMessages: messages, hiddenCount: 0 }; - // Auto-expand when user was seeing all and new messages arrive — derived state sync. - // Reading/updating ref during render is intentional (React docs: derived state sync). - /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ + let significantSeen = 0; + let cutoff = total; + for (let i = 0; i < total; i++) { + if (!isLeadThought(messages[i])) { + significantSeen++; + if (significantSeen > visibleCount) { + cutoff = i; + break; + } + } + } - const wasShowingAll = wasShowingAllRef.current; - if (wasShowingAll && hiddenCount > 0) { - setVisibleCount(messages.length); - } - wasShowingAllRef.current = hiddenCount === 0; - - const visibleMessages = useMemo( - () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), - [messages, visibleCount, hiddenCount] - ); + const significantTotal = + significantSeen + + (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0); + const hidden = Math.max(0, significantTotal - visibleCount); + return { + visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages, + hiddenCount: hidden, + }; + }, [messages, visibleCount]); // Group consecutive lead thoughts into collapsible blocks. const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); @@ -209,6 +215,7 @@ export const ActivityTimeline = ({ }, [timelineItems]); // Determine which items are "new" (should animate). + /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ const newItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { @@ -280,25 +287,50 @@ export const ActivityTimeline = ({ ? item.group.thoughts[0].leadSessionId : item.message.leadSessionId; + // Pin the newest thought group (if first) so it stays at the top and doesn't jump. + const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; + const startIndex = pinnedThoughtGroup ? 1 : 0; + return (
- {timelineItems.map((item, index) => { + {/* Pinned (newest) thought group — always at top */} + {pinnedThoughtGroup && + (() => { + const { group } = pinnedThoughtGroup; + const firstThought = group.thoughts[0]; + const info = memberInfo.get(firstThought.from); + const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; + return ( + + ); + })()} + + {/* Remaining items */} + {timelineItems.slice(startIndex).map((item, index) => { + const realIndex = index + startIndex; + // Session boundary separator (messages sorted desc — new on top) let sessionSeparator: React.JSX.Element | null = null; - if (index > 0) { - const prevSessionId = getItemSessionId(timelineItems[index - 1]); + if (realIndex > 0) { + const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); const currSessionId = getItemSessionId(item); if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { sessionSeparator = (
-
- - New session - -
+
+ New session +
); } @@ -315,8 +347,10 @@ export const ActivityTimeline = ({ ); @@ -342,7 +376,7 @@ export const ActivityTimeline = ({ recipientColor={recipientColor} isUnread={isUnread} isNew={newItemKeys.has(messageKey)} - zebraShade={zebraShadeSet.has(index)} + zebraShade={zebraShadeSet.has(realIndex)} memberColorMap={colorMap} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onCreateTask={onCreateTaskFromMessage} @@ -377,7 +411,7 @@ export const ActivityTimeline = ({ +{hiddenCount} older - + + ) : null}
) : null}
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index d1a63150..000f7b8c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -25,7 +25,6 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 39863d44..5c48993f 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -219,7 +219,6 @@ export const TaskAttachments = ({ void handlePreview(att); }} onDelete={() => { - // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise void handleDelete(att.id, att.mimeType); }} onDataLoaded={handleThumbLoaded} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 0d83452f..1415949b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -13,7 +13,6 @@ import { useStore } from '@renderer/store'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index cb38faa5..cf4bd2c9 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,10 +1,82 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; +import { Check, ChevronDown } from 'lucide-react'; + +// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- + +/** Anthropic — official "A" lettermark (Simple Icons) */ +const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +/** OpenAI — official hexagonal knot logo (Simple Icons) */ +const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +/** Google Gemini — official sparkle/star mark (Simple Icons) */ +const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +/** Local — server rack icon */ +const LocalIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + + + + + +); + +// --- Provider definitions --- + +interface ProviderDef { + id: string; + label: string; + icon: React.FC<{ className?: string }>; + comingSoon: boolean; +} + +const PROVIDERS: ProviderDef[] = [ + { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false }, + { id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true }, + { id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true }, + { id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true }, +]; + +const ACTIVE_PROVIDER = PROVIDERS[0]; + +// --- Model options (Anthropic only for now) --- const MODEL_OPTIONS = [ - { value: '', label: 'Default (account setting)' }, + { value: '', label: 'Default' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'sonnet', label: 'Sonnet 4.5' }, { value: 'haiku', label: 'Haiku 4.5' }, @@ -35,28 +107,132 @@ export const TeamModelSelector: React.FC = ({ value, onValueChange, id, -}) => ( -
- -
- {MODEL_OPTIONS.map((opt) => ( - - ))} +}) => { + const [dropdownOpen, setDropdownOpen] = useState(false); + const containerRef = useRef(null); + + // Close dropdown on click outside + useEffect(() => { + if (!dropdownOpen) return; + + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [dropdownOpen]); + + const ProviderIcon = ACTIVE_PROVIDER.icon; + + return ( +
+ +
+
+ {/* Provider button */} + + + {/* Model pills */} + {MODEL_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Provider dropdown */} + {dropdownOpen && ( +
+ {PROVIDERS.map((provider, index) => { + const Icon = provider.icon; + const isActive = provider.id === ACTIVE_PROVIDER.id; + const isFirst = index === 0; + const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon; + + return ( + + {prevWasActive && !isFirst && ( +
+ )} + + + ); + })} +
+ )} +
-
-); + ); +}; diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 470c5562..e8e86c5b 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -9,7 +10,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { getMemberColor } from '@shared/constants/memberColors'; -import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ChevronDown, ChevronRight, Info } from 'lucide-react'; import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -48,6 +49,7 @@ export const MemberDraftRow = ({ }: MemberDraftRowProps): React.JSX.Element => { const memberColorSet = getTeamColorSet(getMemberColor(index)); const [workflowExpanded, setWorkflowExpanded] = useState(false); + const [modelExpanded, setModelExpanded] = useState(false); // Pre-warm file list cache when workflow section is expanded useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null); @@ -162,6 +164,19 @@ export const MemberDraftRow = ({ Workflow ) : null} +
) : null} + {modelExpanded && ( +
+
+ {}} /> +
+
+ +

+ Claude Code doesn't support per-member model selection yet — all teammates + inherit the team launch model. We plan to solve this via a local proxy. +

+
+
+ )}
); }; diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 42591e0c..3cf05dbf 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { - SubagentRecentMessagesPreview, type SubagentPreviewMessage, + SubagentRecentMessagesPreview, } from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { formatDuration } from '@renderer/utils/formatters'; +import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; import { AlertCircle, ChevronDown, @@ -18,9 +20,6 @@ import { MessageSquare, } from 'lucide-react'; -import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; -import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; - import type { EnhancedChunk } from '@renderer/types/data'; import type { MemberLogSummary } from '@shared/types'; @@ -58,21 +57,78 @@ export const MemberLogsTab = ({ showLeadPreview = false, onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { + const MIN_REFRESH_VISIBLE_MS = 250; const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), [taskWorkIntervals] ); + const isMountedRef = useRef(true); const hasLoadedRef = useRef(false); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const refreshCountRef = useRef(0); + const refreshBeganAtRef = useRef(null); + const refreshHideTimeoutRef = useRef | null>(null); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); + const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); + useEffect(() => { + return () => { + isMountedRef.current = false; + if (refreshHideTimeoutRef.current) { + clearTimeout(refreshHideTimeoutRef.current); + refreshHideTimeoutRef.current = null; + } + }; + }, []); + + useEffect(() => { + expandedIdRef.current = expandedId; + }, [expandedId]); + + const beginRefreshing = useCallback((): void => { + if (refreshCountRef.current === 0) { + refreshBeganAtRef.current = Date.now(); + if (refreshHideTimeoutRef.current) { + clearTimeout(refreshHideTimeoutRef.current); + refreshHideTimeoutRef.current = null; + } + } + refreshCountRef.current += 1; + if (isMountedRef.current) setRefreshing(true); + }, []); + + const endRefreshing = useCallback((): void => { + refreshCountRef.current = Math.max(0, refreshCountRef.current - 1); + if (refreshCountRef.current > 0) { + if (isMountedRef.current) setRefreshing(true); + return; + } + + const beganAt = refreshBeganAtRef.current; + refreshBeganAtRef.current = null; + const elapsed = beganAt ? Date.now() - beganAt : Number.POSITIVE_INFINITY; + + if (!isMountedRef.current) return; + if (elapsed >= MIN_REFRESH_VISIBLE_MS) { + setRefreshing(false); + return; + } + + const remaining = Math.max(0, MIN_REFRESH_VISIBLE_MS - elapsed); + refreshHideTimeoutRef.current = setTimeout(() => { + refreshHideTimeoutRef.current = null; + if (!isMountedRef.current) return; + if (refreshCountRef.current === 0) setRefreshing(false); + }, remaining); + }, []); + const getRowId = useCallback((log: MemberLogSummary): string => { return log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` @@ -174,6 +230,7 @@ export const MemberLogsTab = ({ const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const load = async (): Promise => { + let didBeginRefreshing = false; try { if (taskId == null && !memberName) { if (!cancelled) setLogs([]); @@ -182,7 +239,8 @@ export const MemberLogsTab = ({ if (!hasLoadedRef.current) { setLoading(true); } else { - setRefreshing(true); + beginRefreshing(); + didBeginRefreshing = true; } setError(null); @@ -194,10 +252,22 @@ export const MemberLogsTab = ({ intervals: taskWorkIntervals, }) : await api.teams.getMemberLogs(teamName, memberName!); + const nextLogs = Array.isArray(result) ? [...result] : []; + if (!cancelled) { - setLogs(Array.isArray(result) ? [...result] : []); + setLogs(nextLogs); hasLoadedRef.current = true; } + + // Keep expanded session details in sync with the same refresh + // cadence as the summary (counts/titles) while "Updating..." is shown. + if (!cancelled && didBeginRefreshing) { + try { + await refreshExpandedDetailFromLogs(nextLogs); + } catch { + // Keep last successful detail view; avoid flicker on transient failures. + } + } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); @@ -205,7 +275,7 @@ export const MemberLogsTab = ({ } finally { if (!cancelled) { setLoading(false); - setRefreshing(false); + if (didBeginRefreshing) endRefreshing(); } } }; @@ -222,17 +292,45 @@ export const MemberLogsTab = ({ }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); const fetchDetailForLog = useCallback( - async (log: MemberLogSummary): Promise => { + async ( + log: MemberLogSummary, + options?: { bypassCache?: boolean } + ): Promise => { if (log.kind === 'subagent') { - const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); - return (d?.chunks ?? null) as EnhancedChunk[] | null; + const d = await api.getSubagentDetail( + log.projectId, + log.sessionId, + log.subagentId, + options + ); + return d?.chunks ?? null; } - const d = await api.getSessionDetail(log.projectId, log.sessionId); + const d = await api.getSessionDetail(log.projectId, log.sessionId, options); return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; }, [] ); + const refreshExpandedDetailFromLogs = useCallback( + async (nextLogs: MemberLogSummary[]): Promise => { + const rowId = expandedIdRef.current; + if (!rowId) return; + if (!isMountedRef.current) return; + + const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId); + if (!nextExpanded) return; + + const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; + if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; + + const next = await fetchDetailForLog(nextExpanded, { bypassCache: true }); + if (!isMountedRef.current) return; + // Ensure new reference so memoized transforms update. + setDetailChunks(next ? [...next] : null); + }, + [fetchDetailForLog, getRowId, taskId, taskStatus] + ); + useEffect(() => { if (!shouldShowPreview) { setPreviewChunks(null); @@ -269,12 +367,15 @@ export const MemberLogsTab = ({ let cancelled = false; const interval = setInterval(async () => { + beginRefreshing(); try { - const next = await fetchDetailForLog(previewLog); + const next = await fetchDetailForLog(previewLog, { bypassCache: true }); if (cancelled) return; setPreviewChunks(next ? [...next] : null); } catch { // keep last successful preview + } finally { + endRefreshing(); } }, 5000); @@ -282,31 +383,46 @@ export const MemberLogsTab = ({ cancelled = true; clearInterval(interval); }; - }, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]); + }, [ + beginRefreshing, + endRefreshing, + fetchDetailForLog, + previewLog, + shouldShowPreview, + taskStatus, + ]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; - if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; + // When task logs are auto-refreshing, the summary refresh loop also refreshes + // expanded details to keep everything in sync (and avoid duplicate requests). + if (shouldAutoRefreshSummary) return; + if (!expandedLogSummary.isOngoing) return; let cancelled = false; - const interval = setInterval(async () => { + const refreshDetail = async (): Promise => { + beginRefreshing(); try { - const next = await fetchDetailForLog(expandedLogSummary); + const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true }); if (cancelled) return; // Ensure new reference so memoized transforms update. setDetailChunks(next ? [...next] : null); } catch { // Keep last successful data; avoid flicker during transient errors. + } finally { + endRefreshing(); } - }, 5000); + }; + + const interval = setInterval(() => void refreshDetail(), 5000); return () => { cancelled = true; clearInterval(interval); }; - }, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); + }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); const handleExpand = useCallback( async (log: MemberLogSummary) => { @@ -321,7 +437,11 @@ export const MemberLogsTab = ({ setDetailChunks(null); setDetailLoading(true); try { - const chunks = await fetchDetailForLog(log); + const shouldBypassCache = log.isOngoing || taskStatus === 'in_progress'; + const chunks = await fetchDetailForLog( + log, + shouldBypassCache ? { bypassCache: true } : undefined + ); setDetailChunks(chunks ? [...chunks] : null); } catch { setDetailChunks(null); @@ -329,7 +449,7 @@ export const MemberLogsTab = ({ setDetailLoading(false); } }, - [expandedId, fetchDetailForLog, getRowId] + [expandedId, fetchDetailForLog, getRowId, taskStatus] ); if (loading && logs.length === 0) { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 5d918032..ec12000b 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -128,6 +128,7 @@ export const MessageComposer = ({ addFiles, removeAttachment, clearAttachments, + clearError: clearAttachmentError, handlePaste, handleDrop, } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); @@ -155,7 +156,7 @@ export const MessageComposer = ({ // const leadContext = useStore((s) => // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // ); - const supportsAttachments = isLeadRecipient && !!isTeamAlive; + const supportsAttachments = isLeadRecipient; const canAttach = supportsAttachments && canAddMore; const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const canSend = @@ -262,104 +263,6 @@ export const MessageComposer = ({
- - - - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} - {(() => { - const query = recipientSearch.toLowerCase().trim(); - const filtered = query - ? members.filter((m) => m.name.toLowerCase().includes(query)) - : members; - if (filtered.length === 0) { - return ( -
- No results -
- ); - } - return filtered.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - }); - })()} -
-
-
- {isLeadRecipient ? ( <> ) : null} - {!isTeamAlive ? ( - - Team offline - - ) : null} +
+ {!isTeamAlive ? ( + + Team offline + + ) : null} + + + + + + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )} +
+ {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} + {(() => { + const query = recipientSearch.toLowerCase().trim(); + const filtered = query + ? members.filter((m) => m.name.toLowerCase().includes(query)) + : members; + if (filtered.length === 0) { + return ( +
+ No results +
+ ); + } + return filtered.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()} +
+
+
+
- - Mention "create a task" to add it to the board - {sendError ? ( diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index c51e7d7d..e0da951b 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -595,11 +595,32 @@ export const MentionableTextarea = React.forwardRef [ + 'Tip: Use @ to mention team members or search files', + 'Tip: Mention "create a task" to add it to the board', + "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates", + ], + [] + ); + const [tipIndex, setTipIndex] = React.useState(0); + const [tipVisible, setTipVisible] = React.useState(true); + + const advanceTip = React.useCallback(() => { + setTipIndex((prev) => (prev + 1) % rotatingTips.length); + setTipVisible(true); + }, [rotatingTips.length]); + + React.useEffect(() => { + const interval = setInterval(() => { + setTipVisible(false); + setTimeout(advanceTip, 300); + }, 10000); + return () => clearInterval(interval); + }, [advanceTip]); + + const resolvedHintText = hintText ?? rotatingTips[tipIndex]; const showHintRow = showHint && (suggestions.length > 0 || enableFiles); const showFooter = showHintRow || footerRight; @@ -683,7 +704,12 @@ export const MentionableTextarea = React.forwardRef {showHintRow ? ( - {resolvedHintText} + + {resolvedHintText} + ) : ( )} diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx index 47af98e8..4b5db2bd 100644 --- a/src/renderer/components/ui/dialog.tsx +++ b/src/renderer/components/ui/dialog.tsx @@ -31,23 +31,25 @@ const DialogContent = React.forwardRef< >(({ className, children, ...props }, ref) => ( - -
- +
+
+ Close + + {children} +
- {children} - +
)); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts index 2833ea20..ca17ffb3 100644 --- a/src/renderer/hooks/useAttachments.ts +++ b/src/renderer/hooks/useAttachments.ts @@ -23,6 +23,7 @@ interface UseAttachmentsReturn { addFiles: (files: FileList | File[]) => Promise; removeAttachment: (id: string) => void; clearAttachments: () => void; + clearError: () => void; handlePaste: (event: React.ClipboardEvent) => void; handleDrop: (event: React.DragEvent) => void; } @@ -110,7 +111,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR // Transitioning to non-persistent context: flush pending save and clear stale state flushPending(); attachmentsRef.current = []; - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset on key transition setAttachments([]); return; } @@ -120,7 +120,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR flushPending(); // Clear stale attachments from previous persistenceKey before loading attachmentsRef.current = []; - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load setAttachments([]); void (async () => { const raw = await draftStorage.loadDraft(persistenceKey); @@ -221,6 +220,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR [schedulePersist] ); + const clearError = useCallback(() => { + setError(null); + }, []); + const clearAttachments = useCallback(() => { if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -282,6 +285,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR addFiles, removeAttachment, clearAttachments, + clearError, handlePaste, handleDrop, }; diff --git a/src/renderer/index.css b/src/renderer/index.css index 563130fa..fec49dd6 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -642,6 +642,22 @@ body { animation: chat-message-enter 350ms ease-out both; } +@keyframes thought-expand { + from { + max-height: 0; + opacity: 0; + overflow: hidden; + } + to { + max-height: 500px; + opacity: 1; + } +} + +.thought-expand-in { + animation: thought-expand 350ms ease-out both; +} + .skeleton-card { animation: skeleton-fade-in 0.4s ease-out both; position: relative; @@ -734,6 +750,21 @@ body { background-color: #12131a; } +/* CLI Logs compact density — reduces item height inside CliLogsRichView */ +.cli-logs-compact [role='button'] { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + gap: 0.375rem; +} +.cli-logs-compact [role='button'] .text-sm { + font-size: 0.75rem; + line-height: 1rem; +} +.cli-logs-compact [role='button'] .text-xs { + font-size: 0.625rem; + line-height: 0.875rem; +} + :root.light .checkerboard-bg { background-image: linear-gradient(45deg, #e2e8f0 25%, transparent 25%), diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 1ca674fc..204f0fe2 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -602,6 +602,26 @@ export const createTeamSlice: StateCreator = (set, if (get().selectedTeamName !== teamName) { return; } + // Eagerly patch teamByName with color/displayName from detailed data + // so that tab color renders immediately without waiting for fetchTeams() + const prevByName = get().teamByName; + const existingEntry = prevByName[teamName]; + const configColor = data.config.color; + if (configColor && (!existingEntry || existingEntry?.color !== configColor)) { + const patched: TeamSummary = existingEntry + ? { ...existingEntry, color: configColor, displayName: data.config.name || teamName } + : { + teamName, + displayName: data.config.name || teamName, + description: data.config.description ?? '', + color: configColor, + memberCount: data.members.length, + taskCount: 0, + lastActivity: null, + }; + set({ teamByName: { ...prevByName, [teamName]: patched } }); + } + set({ selectedTeamName: teamName, selectedTeamData: data, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index d8227f7a..2b43e502 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -614,13 +614,18 @@ export interface ElectronAPI { maxResults?: number ) => Promise; searchAllProjects: (query: string, maxResults?: number) => Promise; - getSessionDetail: (projectId: string, sessionId: string) => Promise; + getSessionDetail: ( + projectId: string, + sessionId: string, + options?: { bypassCache?: boolean } + ) => Promise; getSessionMetrics: (projectId: string, sessionId: string) => Promise; getWaterfallData: (projectId: string, sessionId: string) => Promise; getSubagentDetail: ( projectId: string, sessionId: string, - subagentId: string + subagentId: string, + options?: { bypassCache?: boolean } ) => Promise; getSessionGroups: (projectId: string, sessionId: string) => Promise; getSessionsByIds: ( diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 06220230..660eafb4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -186,6 +186,14 @@ export interface AttachmentFileData { mimeType: AttachmentMediaType; } +/** Lightweight metadata for a single tool call (for UI display in tooltips). */ +export interface ToolCallMeta { + /** Tool name, e.g. "Read", "Bash", "Grep" */ + name: string; + /** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */ + preview?: string; +} + export interface InboxMessage { from: string; to?: string; @@ -199,6 +207,10 @@ export interface InboxMessage { attachments?: AttachmentMeta[]; /** Lead session ID that produced this message (for session boundary detection). */ leadSessionId?: string; + /** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */ + toolSummary?: string; + /** Structured tool call details for tooltip display. */ + toolCalls?: ToolCallMeta[]; } export interface SendMessageRequest { @@ -208,6 +220,8 @@ export interface SendMessageRequest { from?: string; attachments?: AttachmentPayload[]; source?: InboxMessage['source']; + /** Lead session ID for session boundary detection. */ + leadSessionId?: string; } export interface SendMessageResult { diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts new file mode 100644 index 00000000..72ee83be --- /dev/null +++ b/src/shared/utils/toolSummary.ts @@ -0,0 +1,119 @@ +import type { ToolCallMeta } from '@shared/types'; + +export interface ToolSummaryData { + total: number; + byName: Record; +} + +export function buildToolSummary(content: Record[]): string | undefined { + const counts = new Map(); + for (const block of content) { + if ( + block && + typeof block === 'object' && + block.type === 'tool_use' && + typeof block.name === 'string' + ) { + counts.set(block.name, (counts.get(block.name) ?? 0) + 1); + } + } + const total = Array.from(counts.values()).reduce((a, b) => a + b, 0); + if (total === 0) return undefined; + const parts = Array.from(counts.entries()) + .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) + .join(', '); + return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`; +} + +export function parseToolSummary(summary: string | undefined): ToolSummaryData | null { + if (!summary) return null; + const match = /^(\d+)\s+tools?\s+\(([^)]+)\)$/.exec(summary); + if (!match) return null; + const byName: Record = {}; + for (const part of match[2].split(', ')) { + const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part); + if (m) { + byName[m[2]] = parseInt(m[1], 10); + } else { + byName[part] = 1; + } + } + return { total: parseInt(match[1], 10), byName }; +} + +export function formatToolSummary(data: ToolSummaryData): string { + const parts = Object.entries(data.byName) + .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) + .join(', '); + return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`; +} + +/** Format tool summary directly from a Map. */ +export function formatToolSummaryFromMap(counts: Map): string | undefined { + const total = Array.from(counts.values()).reduce((a, b) => a + b, 0); + if (total === 0) return undefined; + const parts = Array.from(counts.entries()) + .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) + .join(', '); + return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`; +} + +/** Format tool summary from an array of ToolCallMeta. */ +export function formatToolSummaryFromCalls(calls: ToolCallMeta[]): string | undefined { + if (calls.length === 0) return undefined; + const counts = new Map(); + for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1); + return formatToolSummaryFromMap(counts); +} + +function baseName(filePath: string): string { + return filePath.split(/[/\\]/).pop() ?? filePath; +} + +function truncateStr(str: string, max: number): string { + return str.length <= max ? str : str.slice(0, max) + '...'; +} + +/** Extract a short human-readable preview from tool_use input arguments. */ +export function extractToolPreview( + name: string, + input: Record +): string | undefined { + switch (name) { + case 'Read': + case 'Edit': + case 'Write': + return typeof input.file_path === 'string' ? baseName(input.file_path) : undefined; + case 'Bash': + return typeof input.description === 'string' + ? truncateStr(input.description, 60) + : typeof input.command === 'string' + ? truncateStr(input.command, 60) + : undefined; + case 'Grep': + case 'Glob': + return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined; + case 'Agent': + case 'TaskCreate': + return typeof input.prompt === 'string' + ? input.prompt + : typeof input.description === 'string' + ? input.description + : undefined; + case 'WebFetch': + if (typeof input.url === 'string') { + try { + return new URL(input.url).hostname; + } catch { + return truncateStr(input.url, 40); + } + } + return undefined; + case 'WebSearch': + return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined; + default: { + const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command; + return typeof v === 'string' ? truncateStr(v, 50) : undefined; + } + } +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 522dd469..959bc7cf 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -274,7 +274,6 @@ describe('ipc teams handlers', () => { messages: [ { from: 'team-lead', - to: 'user', text: 'Hello there', timestamp: '2026-02-23T10:00:00.000Z', read: true, @@ -287,7 +286,6 @@ describe('ipc teams handlers', () => { provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { from: 'team-lead', - to: 'user', text: 'Hello there', timestamp: '2026-02-23T10:00:01.000Z', read: true, diff --git a/test/main/services/team/MemberStatsComputer.test.ts b/test/main/services/team/MemberStatsComputer.test.ts index 5c62acf7..058db67d 100644 --- a/test/main/services/team/MemberStatsComputer.test.ts +++ b/test/main/services/team/MemberStatsComputer.test.ts @@ -1,6 +1,49 @@ import { describe, expect, it } from 'vitest'; -import { estimateBashLinesChanged } from '../../../../src/main/services/team/MemberStatsComputer'; +import { + estimateBashLinesChanged, + isValidFilePath, +} from '../../../../src/main/services/team/MemberStatsComputer'; + +describe('isValidFilePath', () => { + it('rejects null-like values', () => { + expect(isValidFilePath('null')).toBe(false); + expect(isValidFilePath('null;')).toBe(false); + expect(isValidFilePath('null;,')).toBe(false); + expect(isValidFilePath('undefined')).toBe(false); + expect(isValidFilePath('None')).toBe(false); + expect(isValidFilePath('false')).toBe(false); + expect(isValidFilePath('true')).toBe(false); + expect(isValidFilePath('')).toBe(false); + }); + + it('rejects paths without slash', () => { + expect(isValidFilePath('somefile')).toBe(false); + expect(isValidFilePath('a')).toBe(false); + }); + + it('rejects very short paths', () => { + expect(isValidFilePath('/')).toBe(false); + }); + + it('accepts valid file paths', () => { + expect(isValidFilePath('/tmp/file.txt')).toBe(true); + expect(isValidFilePath('/Users/dev/project/src/main.ts')).toBe(true); + expect(isValidFilePath('./src/index.ts')).toBe(true); + expect(isValidFilePath('src/utils/helper.ts')).toBe(true); + }); + + it('strips trailing punctuation before validation', () => { + expect(isValidFilePath('/tmp/file.txt;')).toBe(true); + expect(isValidFilePath('/tmp/file.txt,')).toBe(true); + expect(isValidFilePath('/tmp/file.txt.')).toBe(true); + }); + + it('handles whitespace', () => { + expect(isValidFilePath(' /tmp/file.txt ')).toBe(true); + expect(isValidFilePath(' null ')).toBe(false); + }); +}); describe('estimateBashLinesChanged', () => { it('returns zero for simple non-writing commands', () => {