feat: enhance tool summary integration in messaging services and UI components

- Added tool summary generation in TeamDataService and TeamProvisioningService to capture tool usage details in messages.
- Updated InboxMessage type to include toolSummary for better tracking of tool usage in assistant messages.
- Enhanced LeadThoughtsGroupRow to aggregate and display total tool usage across thoughts, improving visibility of tool interactions.
- Refactored TeamModelSelector to incorporate provider icons and improve user interface for model selection.
- Updated README and CLAUDE.md to reflect new command usage and features related to tool summaries.
This commit is contained in:
iliya 2026-03-06 13:51:37 +02:00
parent 30b4e74924
commit 9368e7639d
10 changed files with 756 additions and 29 deletions

View file

@ -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

View file

@ -38,8 +38,9 @@ A new approach to task management with AI agents.
- **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
- **Solo mode** — a one-member team: a single agent (regular claude process) 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
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **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
@ -204,8 +205,11 @@ pnpm dist # macOS + Windows + Linux
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 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
- [ ] Сurate 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
- [ ]
---

View file

@ -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: маркер `<!-- hydra: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 — парсер <think> тегов (~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 + `<think>` парсер) | Нет |
| 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.

View file

@ -17,6 +17,7 @@ import {
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { buildToolSummary } from '@shared/utils/toolSummary';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@ -1486,6 +1487,8 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
const toolSummary = buildToolSummary(content as Record<string, unknown>[]);
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
.slice(0, 50)
@ -1500,6 +1503,7 @@ export class TeamDataService {
source: 'lead_session',
leadSessionId: config.leadSessionId,
messageId: `lead-session-${timestamp}-${textPrefix}`,
toolSummary,
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}

View file

@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { buildToolSummary } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@ -2913,12 +2914,13 @@ export class TeamProvisioningService {
// captureSendMessageToUser() handles it separately.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
if (cleanText.length > 0) {
run.leadMsgSeq += 1;
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
const toolSummary = buildToolSummary(content ?? []);
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
@ -2927,6 +2929,7 @@ export class TeamProvisioningService {
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
source: 'lead_process',
toolSummary,
};
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);

View file

@ -76,6 +76,7 @@ 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,
});
}

View file

@ -11,6 +11,7 @@ import {
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import type { InboxMessage } from '@shared/types';
@ -131,6 +132,22 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
// Aggregate tool usage across all thoughts in this group
const totalToolSummary = useMemo(() => {
const merged: Record<string, number> = {};
let total = 0;
for (const t of thoughts) {
const parsed = parseToolSummary(t.toolSummary);
if (!parsed) continue;
total += parsed.total;
for (const [name, count] of Object.entries(parsed.byName)) {
merged[name] = (merged[name] ?? 0) + count;
}
}
if (total === 0) return null;
return formatToolSummary({ total, byName: merged });
}, [thoughts]);
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
@ -225,6 +242,11 @@ export const LeadThoughtsGroupRow = ({
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
{totalToolSummary && (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{totalToolSummary}
</span>
)}
</div>
{/* Scrollable body — fixed height, always visible */}

View file

@ -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 }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M17.304 3.541h-3.672l6.696 16.918H24Zm-10.608 0L0 20.459h3.744l1.37-3.553h7.005l1.369 3.553h3.744L10.536 3.541Zm-.371 10.223 2.291-5.946 2.292 5.946Z" />
</svg>
);
/** OpenAI — official hexagonal knot logo (Simple Icons) */
const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.998 5.998 0 0 0-3.992 2.9 6.042 6.042 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.612-1.5z" />
</svg>
);
/** Google Gemini — official sparkle/star mark (Simple Icons) */
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
</svg>
);
/** Local — server rack icon */
const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="3" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
<rect x="3" y="14" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
<circle cx="7" cy="6.5" r="1" fill="currentColor" />
<circle cx="7" cy="17.5" r="1" fill="currentColor" />
<line
x1="10.5"
y1="6.5"
x2="17.5"
y2="6.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<line
x1="10.5"
y1="17.5"
x2="17.5"
y2="17.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
);
// --- 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<TeamModelSelectorProps> = ({
value,
onValueChange,
id,
}) => (
<div className="flex items-center gap-2.5">
<Label htmlFor={id} className="label-optional shrink-0">
Model (optional)
</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{MODEL_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
}) => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div className="mb-5">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Model (optional)
</Label>
<div ref={containerRef} className="relative inline-block">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{/* Provider button */}
<button
type="button"
className={cn(
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
dropdownOpen
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
)}
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<ProviderIcon className="size-3.5" />
<span>{ACTIVE_PROVIDER.label}</span>
<ChevronDown
className={cn(
'size-3 transition-transform duration-200',
dropdownOpen && 'rotate-180'
)}
/>
</button>
{/* Model pills */}
{MODEL_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
{/* Provider dropdown */}
{dropdownOpen && (
<div
className="absolute bottom-full left-0 z-50 mb-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)',
}}
>
{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 (
<React.Fragment key={provider.id}>
{prevWasActive && !isFirst && (
<div
className="mx-2 my-1 border-t"
style={{ borderColor: 'var(--color-border-subtle)' }}
/>
)}
<button
type="button"
disabled={provider.comingSoon}
onClick={() => {
if (!provider.comingSoon) {
setDropdownOpen(false);
}
}}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
isActive && 'bg-indigo-500/10 text-indigo-400',
provider.comingSoon && 'cursor-not-allowed opacity-40',
!isActive && !provider.comingSoon && 'hover:bg-white/5'
)}
style={
!isActive && !provider.comingSoon
? { color: 'var(--color-text-secondary)' }
: undefined
}
>
<Icon className="size-3.5 shrink-0" />
<span className="flex-1">{provider.label}</span>
{provider.comingSoon && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Coming Soon
</span>
)}
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
</div>
</div>
);
);
};

View file

@ -199,6 +199,8 @@ 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;
}
export interface SendMessageRequest {

View file

@ -0,0 +1,47 @@
export interface ToolSummaryData {
total: number;
byName: Record<string, number>;
}
export function buildToolSummary(content: Record<string, unknown>[]): string | undefined {
const counts = new Map<string, number>();
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 = summary.match(/^(\d+)\s+tools?\s+\(([^)]+)\)$/);
if (!match) return null;
const byName: Record<string, number> = {};
for (const part of match[2].split(', ')) {
const m = part.match(/^(\d+)\s+(.+)$/);
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})`;
}