diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4ce39e36..360e9185 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,11 +1,17 @@ # Contributing -Thanks for contributing to Claude Agent Teams UI. +Thanks for contributing to Claude Agent Teams UI! + +## Before You Start + +For big features and major changes, please discuss them in our [Discord](https://discord.gg/RgBHMBsn) first so we can figure out the best approach together and avoid conflicts. + +Small fixes, bug reports, and minor improvements are always welcome - just open a PR. ## Prerequisites - Node.js 20+ - pnpm 10+ -- macOS or Windows +- macOS, Windows, or Linux ## Setup ```bash @@ -22,12 +28,16 @@ pnpm test pnpm build ``` +Or all at once: +```bash +pnpm check +``` + ## Pull Request Guidelines -- Keep changes focused and small — one purpose per PR. +- Keep changes focused and small - one purpose per PR. - Add/adjust tests for behavior changes. - Update docs when changing public behavior or setup. - Use clear PR titles and include a short validation checklist. -- **Large changes (new features, new dependencies, large data additions) must have a discussion in an Issue first.** Do not open a large PR without prior agreement on the approach. - Avoid committing large hardcoded data blobs. If data can be fetched at runtime or generated at build time, prefer that approach. ## AI-Assisted Contributions @@ -35,15 +45,14 @@ pnpm build AI coding tools are welcome, but **you are responsible for what you submit**: - **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output. -- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools (e.g. `docs/plans/`, `.speckit/`, etc.) do not belong in the repository. -- **Test it yourself.** AI-generated code must be manually verified — run the app, confirm the feature works, check edge cases. +- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools do not belong in the repository. +- **Test it yourself.** AI-generated code must be manually verified - run the app, confirm the feature works, check edge cases. - **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it. ## What Does NOT Belong in the Repo - Personal planning/workflow artifacts (AI session plans, task lists, etc.) - Large static data that could be fetched at runtime - Generated files that aren't part of the build output -- Experimental features without prior discussion ## Commit Style - Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`). @@ -52,7 +61,7 @@ AI coding tools are welcome, but **you are responsible for what you submit**: ## Reporting Bugs Please include: - OS version -- app version / commit hash -- repro steps -- expected vs actual behavior -- logs/screenshots when possible +- App version / commit hash +- Repro steps +- Expected vs actual behavior +- Logs/screenshots when possible diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba3cdbf..ae068fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,12 @@ name: CI on: push: - branches: [main] + branches: [main, dev] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -18,11 +19,11 @@ on: - 'tailwind.config.*' - 'eslint.config.*' pull_request: - branches: [main] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -58,9 +59,10 @@ jobs: uses: actions/cache@v4 with: path: .eslintcache - key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*') }} - restore-keys: | - eslint-${{ runner.os }}- + key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }} + + - name: Auto-fix import sort (Node version parity) + run: npx eslint src/ --fix --no-cache || true - name: Validate workspace truth gate run: pnpm check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5833d37..4766dbae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,7 +143,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.dmg release/*.zip release/*.blockmap release/*.yml; do + for f in release/*.dmg release/*.zip release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -213,7 +213,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.exe release/*.blockmap release/*.yml; do + for f in release/*.exe release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -285,7 +285,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap release/*.yml; do + for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -326,3 +326,85 @@ jobs: gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber rm -f "$STABLE_NAME" done + + - name: Publish canonical updater metadata + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF#refs/tags/v}" + TAG="v${VERSION}" + REPO="${GITHUB_REPOSITORY}" + RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" + TMP_DIR="$(mktemp -d)" + cd "$TMP_DIR" + + sha512_base64() { + openssl dgst -sha512 -binary "$1" | openssl base64 -A + } + + file_size() { + wc -c < "$1" | tr -d '[:space:]' + } + + download_asset() { + local name="$1" + curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}" + } + + # Canonical Windows feed + download_asset "Claude-Agent-Teams-UI-Setup.exe" + WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)" + WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)" + cat > latest.yml < latest-linux.yml < latest-mac.yml </`.** +See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns. + ## Data Sources ~/.claude/projects/{encoded-path}/*.jsonl - Session files ~/.claude/todos/{sessionId}.json - Todo data @@ -64,6 +68,13 @@ Path encoding: `/Users/name/project` → `-Users-name-project` ## Critical Concepts +### Agent Blocks +- Use `wrapAgentBlock(text)` from `@shared/constants/agentBlocks` to wrap agent-only content. + Do NOT manually concatenate `AGENT_BLOCK_OPEN/CLOSE` — the wrapper handles trimming and formatting. +- `stripAgentBlocks(text)` — removes agent blocks for UI display +- `unwrapAgentBlock(block)` — extracts content from a single block +- Agent blocks are hidden from the user in UI, used for internal instructions between agents. + ### isMeta Flag - `isMeta: false` = Real user message (creates new chunks) - `isMeta: true` = Internal message (tool results, system-generated) diff --git a/README.md b/README.md index 3e1e5fbd..51909678 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@

100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.

+
@@ -303,6 +304,11 @@ pnpm dist # macOS + Windows + Linux - [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them) - [ ] Curate what context each agent sees (files, docs, MCP servers, skills) - [ ] Slash commands +- [ ] Outgoing message queue — queue user messages while the lead (or agent) is busy; clear agent-busy status in the UI; flush to stdin or relay from inbox when idle (durable queue on disk for the lead inbox path) +- [ ] `createTasksBatch` — IPC/service API to create many team tasks in one call (playbooks, markdown checklist import, scripts); complements single `createTask` +- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow +- [ ] Custom kanban columns +- [ ] Run terminal commands --- diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js index 464aade2..50bb54f8 100644 --- a/agent-teams-controller/src/index.js +++ b/agent-teams-controller/src/index.js @@ -1,5 +1,7 @@ const controller = require('./controller.js'); +const mcpToolCatalog = require('./mcpToolCatalog.js'); module.exports = { ...controller, + ...mcpToolCatalog, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js new file mode 100644 index 00000000..3146bad4 --- /dev/null +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -0,0 +1,115 @@ +const AGENT_TEAMS_TASK_TOOL_NAMES = [ + 'member_briefing', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_briefing', + 'task_complete', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_list', + 'task_set_clarification', + 'task_set_owner', + 'task_set_status', + 'task_start', + 'task_unlink', +]; + +const AGENT_TEAMS_REVIEW_TOOL_NAMES = [ + 'review_approve', + 'review_request', + 'review_request_changes', + 'review_start', +]; + +const AGENT_TEAMS_MESSAGE_TOOL_NAMES = ['message_send']; + +const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES = [ + 'cross_team_get_outbox', + 'cross_team_list_targets', + 'cross_team_send', +]; + +const AGENT_TEAMS_PROCESS_TOOL_NAMES = [ + 'process_list', + 'process_register', + 'process_stop', + 'process_unregister', +]; + +const AGENT_TEAMS_KANBAN_TOOL_NAMES = [ + 'kanban_add_reviewer', + 'kanban_clear', + 'kanban_get', + 'kanban_list_reviewers', + 'kanban_remove_reviewer', + 'kanban_set_column', +]; + +const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop']; + +const AGENT_TEAMS_MCP_TOOL_GROUPS = [ + { + id: 'task', + teammateOperational: true, + toolNames: AGENT_TEAMS_TASK_TOOL_NAMES, + }, + { + id: 'kanban', + teammateOperational: false, + toolNames: AGENT_TEAMS_KANBAN_TOOL_NAMES, + }, + { + id: 'review', + teammateOperational: true, + toolNames: AGENT_TEAMS_REVIEW_TOOL_NAMES, + }, + { + id: 'message', + teammateOperational: true, + toolNames: AGENT_TEAMS_MESSAGE_TOOL_NAMES, + }, + { + id: 'process', + teammateOperational: true, + toolNames: AGENT_TEAMS_PROCESS_TOOL_NAMES, + }, + { + id: 'runtime', + teammateOperational: false, + toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, + }, + { + id: 'crossTeam', + teammateOperational: true, + toolNames: AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + }, +]; + +const AGENT_TEAMS_REGISTERED_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.flatMap((group) => [ + ...group.toolNames, +]); + +const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.filter( + (group) => group.teammateOperational +).flatMap((group) => [...group.toolNames]); + +const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES = + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`); + +module.exports = { + AGENT_TEAMS_TASK_TOOL_NAMES, + AGENT_TEAMS_REVIEW_TOOL_NAMES, + AGENT_TEAMS_MESSAGE_TOOL_NAMES, + AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + AGENT_TEAMS_PROCESS_TOOL_NAMES, + AGENT_TEAMS_KANBAN_TOOL_NAMES, + AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_MCP_TOOL_GROUPS, + AGENT_TEAMS_REGISTERED_TOOL_NAMES, + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, +}; diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 84e58e5e..c673abcd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,5 +1,9 @@ # Release Guide +## Published: v1.1.0 (2026-03-26) + +Minor release: React 19 + Electron 40 migration, start-task-by-user, auth troubleshooting guide, syntax highlighting for R/Ruby/PHP/SQL, search performance improvements, cost tracking accuracy, WSL/Windows path fixes. Full list: [CHANGELOG.md](./CHANGELOG.md). + ## Published: v1.0.0 (2026-03-19) Initial release: Claude Agent Teams UI with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md). diff --git a/docs/research/acp-deep-dive.md b/docs/research/acp-deep-dive.md new file mode 100644 index 00000000..bad16629 --- /dev/null +++ b/docs/research/acp-deep-dive.md @@ -0,0 +1,847 @@ +# Agent Client Protocol (ACP) — Deep Technical Analysis + +> Дата исследования: 2026-03-24 +> Контекст: интеграция ACP в Claude Agent Teams UI (Electron 40.x) + +--- + +## 1. Что такое ACP? + +**Agent Client Protocol (ACP)** — это открытый стандарт коммуникации между редакторами кода (IDE) и AI-агентами. Создан Zed Industries, поддерживается JetBrains с октября 2025. + +**Аналогия:** LSP (Language Server Protocol) стандартизировал интеграцию языковых серверов с редакторами. ACP делает то же самое для AI coding agents. + +**Проблема, которую решает:** +- Каждый редактор делал кастомную интеграцию для каждого агента (M x N) +- Агенты были привязаны к конкретным IDE +- ACP сводит M x N → M + N (агент реализует ACP один раз, работает во всех IDE) + +**Лицензия:** Apache 2.0 +**Governance:** Lead Maintainers — Ben Brandt (Zed Industries), Sergey Ignatov (JetBrains) + +**Источники:** +- Спецификация: https://agentclientprotocol.com/ +- GitHub: https://github.com/agentclientprotocol/agent-client-protocol +- Zed ACP: https://zed.dev/acp + +> **ВАЖНО:** Существует ТРИ разных протокола с аббревиатурой ACP: +> 1. **Agent Client Protocol** (Zed/JetBrains) — редактор ↔ агент. **Это наш фокус.** +> 2. **Agent Communication Protocol** (IBM BeeAI) — агент ↔ агент. Сливается с A2A (Linux Foundation). Не релевантно. +> 3. **Agent Connect Protocol** (Agntcy Collective) — REST API для remote agents. Не релевантно. + +--- + +## 2. Архитектура протокола + +### 2.1 Транспорт + +| Режим | Транспорт | Формат | Статус | +|-------|-----------|--------|--------| +| **Локальный** | stdio (stdin/stdout) | NDJSON (newline-delimited JSON) | Стабильный | +| **TCP** | TCP socket (порт) | NDJSON | Стабильный (Copilot CLI: `--acp --port 8080`) | +| **Remote** | HTTP / WebSocket | JSON-RPC | **Work in progress** | + +Основной режим: **JSON-RPC 2.0 поверх NDJSON через stdio**. Клиент (IDE) spawn'ит агента как subprocess, stdin/stdout становятся транспортом. + +### 2.2 Типы сообщений + +Два типа (JSON-RPC 2.0): +- **Methods** — request-response пары, ожидают result или error +- **Notifications** — односторонние сообщения, без ответа + +### 2.3 Lifecycle + +``` +Client Agent + | | + |------- initialize --------------->| (версия протокола + capabilities) + |<------ InitializeResponse --------| (agent capabilities) + | | + |------- authenticate ------------->| (если требуется) + |<------ AuthenticateResponse ------| + | | + |------- session/new --------------->| (cwd, mcpServers[]) + |<------ NewSessionResponse ---------| (sessionId) + | | + |------- session/prompt ------------->| (prompt content) + |<~~~~~~ session/update (notification)| (streaming chunks, tool calls, plans) + |<~~~~~~ session/update | + |<--request_permission --------------| (tool approval) + |------- permission response ------->| + |<~~~~~~ session/update | + |<------ PromptResponse -------------| (stopReason) + | | + |------- session/prompt (next) ----->| + | ... | +``` + +### 2.4 Session Update Events (стриминг) + +Во время `prompt` агент шлёт `session/update` notifications: + +| Event | Описание | +|-------|----------| +| `agent_message_chunk` | Текстовый чанк от агента (streaming) | +| `agent_thought_chunk` | Мысли агента (thinking) | +| `user_message_chunk` | Эхо пользовательского ввода | +| `tool_call` | Новый вызов инструмента (pending/completed) | +| `tool_call_update` | Обновление статуса вызова инструмента | +| `plan` | План действий с приоритетами и статусами | +| `available_commands_update` | Обновление доступных команд | +| `config_option_update` | Изменение конфигурации | +| `current_mode_update` | Смена режима сессии | +| `session_info_update` | Метаданные сессии (title, activity) | +| `usage_update` | Потребление токенов (draft) | + +### 2.5 Client-Provided Methods + +Клиент (IDE) предоставляет агенту доступ к: + +| Метод | Описание | Required? | +|-------|----------|-----------| +| `session/request_permission` | Запрос разрешения на выполнение инструмента | **Required** | +| `fs/read_text_file` | Чтение файла | Optional | +| `fs/write_text_file` | Запись файла | Optional | +| `terminal/create` | Создание терминала | Optional | +| `terminal/output` | Получение вывода терминала | Optional | +| `terminal/wait_for_exit` | Ожидание завершения | Optional | +| `terminal/kill` | Завершение процесса | Optional | +| `terminal/release` | Освобождение ресурсов | Optional | + +### 2.6 MCP Integration + +ACP переиспользует JSON-представления из MCP где возможно. Агент может принимать MCP сервера при создании сессии: + +```typescript +connection.newSession({ + cwd: '/path/to/project', + mcpServers: [ + { type: 'stdio', command: 'node', args: ['mcp-server.js'] }, + { type: 'http', url: 'https://mcp.example.com', headers: {} }, + { type: 'sse', url: 'https://mcp.example.com/sse', headers: {} }, + ], +}); +``` + +--- + +## 3. TypeScript SDK — API Surface + +### 3.1 Package Info + +| Характеристика | Значение | +|----------------|----------| +| **npm** | `@agentclientprotocol/sdk` | +| **Версия** | 0.16.1 (март 2026) | +| **Размер** | 863 kB | +| **Dependencies** | **0** (zero dependencies!) | +| **Лицензия** | Apache-2.0 | +| **Dependents** | 245+ пакетов | +| **GitHub stars** | 122 | +| **Contributors** | 31 | +| **Commits** | 544 | +| **Used by** | 823+ проектов | + +**Факт:** ранее публиковался как `@zed-industries/agent-client-protocol`, переименован. + +### 3.2 Exported Classes (4) + +```typescript +import { + ClientSideConnection, // Для клиентов (IDE) — наш интерес + AgentSideConnection, // Для агентов (сервер) + TerminalHandle, // Управление терминалом + RequestError, // Типизированная ошибка +} from '@agentclientprotocol/sdk'; +``` + +### 3.3 Exported Interfaces (2) + +```typescript +interface Client { + requestPermission(params: RequestPermissionRequest): Promise; + sessionUpdate(params: SessionNotification): Promise; + writeTextFile?(params: WriteTextFileRequest): Promise; + readTextFile?(params: ReadTextFileRequest): Promise; + // terminal methods... +} + +interface Agent { + initialize(params: InitializeRequest): Promise; + newSession(params: NewSessionRequest): Promise; + authenticate?(params: AuthenticateRequest): Promise; + prompt(params: PromptRequest): Promise; + cancel?(params: CancelNotification): Promise; + setSessionMode?(params: SetSessionModeRequest): Promise; + // ... +} +``` + +### 3.4 Exported Functions (1) + Variables (3) + +```typescript +// Единственная утилитарная функция — создаёт NDJSON stream +function ndJsonStream(input: WritableStream, output: ReadableStream): Stream; + +// Константы +const PROTOCOL_VERSION: string; // Текущая версия протокола +const AGENT_METHODS: string[]; // Список методов агента +const CLIENT_METHODS: string[]; // Список методов клиента +``` + +### 3.5 Type Aliases (~180+) + +Полный список категорий типов: + +- **Content:** `TextContent`, `ImageContent`, `AudioContent`, `Content`, `ContentBlock`, `ContentChunk` +- **Authentication:** `AuthMethod`, `AuthCapabilities`, `AuthenticateRequest/Response` +- **Sessions:** `SessionId`, `SessionInfo`, `SessionCapabilities`, `SessionUpdate`, `SessionMode` +- **Tools:** `ToolCall`, `ToolCallUpdate`, `ToolCallId`, `ToolCallStatus`, `ToolKind` +- **Permissions:** `RequestPermissionRequest/Response`, `PermissionOption`, `PermissionOptionKind` +- **Plans:** `Plan`, `PlanEntry`, `PlanEntryStatus`, `PlanEntryPriority` +- **Diffs:** `Diff` (`path`, `oldText`, `newText`) +- **File System:** `ReadTextFileRequest/Response`, `WriteTextFileRequest/Response` +- **Terminals:** `Terminal`, `CreateTerminalRequest/Response`, `TerminalExitStatus` +- **MCP:** `McpCapabilities`, `McpServer`, `McpServerStdio`, `McpServerHttp`, `McpServerSse` +- **Protocol:** `InitializeRequest/Response`, `PromptRequest/Response`, `StopReason`, `Cost`, `Usage` +- **Elicitation (draft):** `ElicitationRequest/Response`, `ElicitationSchema` — формы ввода от агента +- **Config:** `SessionConfigOption`, `SessionConfigBoolean`, `SessionConfigSelect` +- **Models:** `ModelId`, `ModelInfo` + +### 3.6 ClientSideConnection — Full API + +```typescript +class ClientSideConnection { + constructor(toClient: (agent: Agent) => Client, stream: Stream); + + // Properties + signal: AbortSignal; // Aborts when connection closes + closed: Promise; // Resolves when connection ends + + // Core methods + initialize(params: InitializeRequest): Promise; + newSession(params: NewSessionRequest): Promise; + prompt(params: PromptRequest): Promise; + authenticate(params: AuthenticateRequest): Promise; + + // Session management + loadSession(params: LoadSessionRequest): Promise; // Resume previous + listSessions(params: ListSessionsRequest): Promise; // List available + setSessionMode(params: SetSessionModeRequest): Promise; + setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise; + + // Unstable/experimental + unstable_forkSession(params: ForkSessionRequest): Promise; + unstable_resumeSession(params: ResumeSessionRequest): Promise; + unstable_closeSession(params: CloseSessionRequest): Promise; + unstable_setSessionModel(params: SetSessionModelRequest): Promise; + unstable_logout(params: LogoutRequest): Promise; + + // Notifications + cancel(params: CancelNotification): Promise; // Cancel ongoing prompt + + // Extensibility + extMethod(method: string, params: Record): Promise>; + extNotification(method: string, params: Record): Promise; +} +``` + +--- + +## 4. Какие агенты поддерживают ACP? + +### Подтверждённые (с доказательствами) + +| Агент | Поддержка ACP | Как реализовано | Источник | +|-------|--------------|-----------------|----------| +| **Gemini CLI** | Нативная (reference implementation) | Встроенный ACP-сервер | [zed.dev/acp](https://zed.dev/acp) | +| **Claude Code** | Через адаптер | `@zed-industries/claude-code-acp` (npm, Apache 2.0) | [GitHub](https://github.com/zed-industries/claude-agent-acp) | +| **Codex CLI** | Через community adapter | Zed adapter | [zed.dev/docs/ai/external-agents](https://zed.dev/docs/ai/external-agents) | +| **GitHub Copilot CLI** | Нативная (public preview) | `copilot --acp` / `copilot --acp --port 8080` | [GitHub Blog](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) | +| **Goose** (Block) | Нативная | Встроенный ACP-сервер | [goose blog](https://block.github.io/goose/blog/2025/10/24/intro-to-agent-client-protocol-acp/) | +| **Junie** (JetBrains) | Нативная | Встроена в JetBrains AI Assistant | [JetBrains](https://www.jetbrains.com/help/ai-assistant/acp.html) | +| **Cline** | Нативная | Встроенный ACP-сервер | [DeepWiki](https://deepwiki.com/cline/cline/12.5-agent-client-protocol-(acp)) | +| **Kiro CLI** | Нативная | Встроенный ACP-сервер | [Kiro docs](https://kiro.dev/docs/cli/acp/) | +| **OpenCode** | Нативная | Встроенный ACP-сервер | [opencode.ai](https://opencode.ai/docs/acp/) | +| **Augment Code** | Нативная | ACP Registry | [Registry](https://agentclientprotocol.com/registry) | +| **Qwen Code** | Нативная | ACP Registry | VS Code ACP Client | + +**Claude Code НЕ имеет нативного `--acp` флага** (есть [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686)). Работает через `@zed-industries/claude-code-acp` адаптер, который использует Claude Agent SDK. + +### IDE/Клиенты с ACP поддержкой + +| Клиент | Статус | +|--------|--------| +| **Zed** | Нативная (создатели протокола) | +| **JetBrains** (IntelliJ, PyCharm и др.) | Нативная (co-maintainer) | +| **Neovim** | Через плагины (CodeCompanion, avante.nvim) | +| **Emacs** | Community extensions | +| **Marimo** (Python notebooks) | Встроенная | +| **VS Code** | **НЕТ** (ключевой вопрос для экосистемы) | +| **Cursor** | **НЕТ** (может появиться если будет спрос) | + +--- + +## 5. Конкретный пример кода (из SDK) + +### Client (IDE side) + +```typescript +import { spawn } from 'node:child_process'; +import { Writable, Readable } from 'node:stream'; +import * as acp from '@agentclientprotocol/sdk'; + +class MyClient implements acp.Client { + async requestPermission(params: acp.RequestPermissionRequest): Promise { + // UI показывает dialog с params.options + return { + outcome: { outcome: 'selected', optionId: params.options[0].optionId }, + }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update; + switch (update.sessionUpdate) { + case 'agent_message_chunk': + if (update.content.type === 'text') { + console.log(update.content.text); // Streaming text + } + break; + case 'tool_call': + console.log(`Tool: ${update.title} (${update.status})`); + break; + case 'tool_call_update': + console.log(`Tool ${update.toolCallId}: ${update.status}`); + break; + case 'plan': + // Plan entries with status/priority + break; + } + } + + async readTextFile(params: acp.ReadTextFileRequest): Promise { + const content = fs.readFileSync(params.path, 'utf-8'); + return { content }; + } + + async writeTextFile(params: acp.WriteTextFileRequest): Promise { + fs.writeFileSync(params.path, params.content); + return {}; + } +} + +async function main() { + // Spawn agent process + const agentProcess = spawn('claude', ['--acp'], { + stdio: ['pipe', 'pipe', 'inherit'], + }); + + // Create NDJSON stream over stdio + const input = Writable.toWeb(agentProcess.stdin!); + const output = Readable.toWeb(agentProcess.stdout!) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + + // Create connection + const client = new MyClient(); + const connection = new acp.ClientSideConnection((_agent) => client, stream); + + // Initialize + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: { create: true, output: true, kill: true }, + }, + }); + + // Create session + const session = await connection.newSession({ + cwd: '/path/to/project', + mcpServers: [], + }); + + // Send prompt (blocks until agent completes turn) + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'Fix the bug in main.ts' }], + }); + + console.log(`Stop reason: ${result.stopReason}`); // 'end_turn' | 'cancelled' | ... +} +``` + +### Process spawning + +**ACP SDK НЕ управляет spawn'ом процесса.** Это ответственность клиента. SDK берёт на себя только протокол поверх уже готового stream (stdin/stdout). + +```typescript +// ACP SDK expects web streams: +const input = Writable.toWeb(childProcess.stdin!); // WritableStream +const output = Readable.toWeb(childProcess.stdout!); // ReadableStream +const stream = acp.ndJsonStream(input, output); // ACP Stream +``` + +--- + +## 6. ACP vs MCP — Различия + +| Аспект | MCP (Model Context Protocol) | ACP (Agent Client Protocol) | +|--------|-----|-----| +| **Создатель** | Anthropic | Zed Industries + JetBrains | +| **Фокус** | Инструменты/данные для модели | Коммуникация IDE ↔ агент | +| **Аналогия** | "Дать человеку лучшие инструменты" | "Собрать команду из людей" | +| **Отношение** | **Что** (доступ к данным/tools) | **Где** (где агент живёт в workflow) | +| **Протокол** | JSON-RPC 2.0 поверх stdio/SSE/HTTP | JSON-RPC 2.0 поверх NDJSON stdio | +| **Типы контента** | Tools, Resources, Prompts | Messages, Tool Calls, Plans, Diffs, Permissions | +| **Стейт** | Stateless на уровне протокола | Stateful (sessions, message history) | +| **Sessions** | Нет (транспортные сессии) | Да (conversation sessions с ID) | +| **Streaming** | Через SSE или notifications | session/update notifications | + +**Ключевое:** ACP и MCP комплементарны. ACP-сессия может принимать MCP-серверы (`mcpServers` в `newSession`). Агент использует MCP для доступа к инструментам, а ACP для общения с IDE. + +--- + +## 7. Зрелость и стабильность + +### Версионирование + +SDK на v0.16.1 (март 2026) — ещё **pre-1.0**. Много `unstable_` методов. + +### Timeline ключевых событий + +| Дата | Событие | +|------|---------| +| Сентябрь 2025 | Zed анонсирует ACP | +| Октябрь 2025 | JetBrains присоединяется | +| Октябрь 2025 | Gemini CLI — первая интеграция | +| Январь 2026 | Copilot CLI ACP public preview | +| Январь 2026 | ACP Registry запущен | +| Февраль 2026 | Session Config Options стабилизированы | +| Март 2026 | session/list + session_info_update стабилизированы | +| Март 2026 | SDK v0.16.1 | + +### Что в Draft (ещё не стабилизировано) + +- `session/close` — закрытие сессий +- `session/fork` — форк сессий +- `session/resume` — возобновление сессий +- Elicitation — формы ввода от агента +- Usage updates — статистика токенов +- Message IDs — идентификаторы сообщений +- Delete in Diff — удаление файлов через diff +- Next Edit Suggestions — предложения следующих правок + +### Breaking Changes + +Протокол на стадии 0.x — breaking changes возможны между минорными версиями. Rename пакета `@zed-industries/agent-client-protocol` → `@agentclientprotocol/sdk` уже произошёл. + +--- + +## 8. Анализ интеграции в наш Electron app + +### 8.1 Текущая архитектура (как мы работаем сейчас) + +Наш стек коммуникации с Claude Code: + +``` +Electron Main Process + └── TeamProvisioningService + ├── spawnCli() → ChildProcess (stream-json) + ├── stdin.write(NDJSON) → Claude CLI + ├── stdout → parse NDJSON lines + │ ├── type: "user" / "assistant" / "result" / "system" + │ ├── type: "control_request" (tool approval) + │ └── result.success → turn complete + └── stderr → logs, error detection +``` + +**Ключевые аргументы CLI:** +``` +--input-format stream-json --output-format stream-json +``` + +**Наша обработка:** +- `HANDLED_STREAM_JSON_TYPES = ['user', 'assistant', 'control_request', 'result', 'system']` +- `stdin.write(message + '\n')` — отправка +- Ручной парсинг NDJSON с carry buffer для неполных строк +- `control_request` → UI dialog для tool approval +- `result.success` → turn complete, process alive +- SIGKILL для остановки (SIGTERM вызывает cleanup) + +### 8.2 Что ACP заменил бы + +| Компонент | Сейчас (stream-json) | С ACP | +|-----------|---------------------|-------| +| **Spawn** | `spawnCli()` | Остаётся наш `spawnCli()` | +| **Transport** | Ручной NDJSON парсинг с carry buffer | `acp.ndJsonStream()` + `ClientSideConnection` | +| **Initialize** | Нет (просто шлём prompt) | `connection.initialize()` — capabilities negotiation | +| **Session** | Нет (implicit) | `connection.newSession()` — explicit session ID | +| **Prompt** | `stdin.write(JSON.stringify({type:'user',...}) + '\n')` | `connection.prompt({sessionId, prompt})` | +| **Streaming** | Ручной парсинг stdout строк | `sessionUpdate()` callback с typed events | +| **Tool approval** | `control_request` парсинг | `requestPermission()` callback | +| **File ops** | Нет (агент делает сам) | `readTextFile()` / `writeTextFile()` callbacks | +| **Terminal** | Нет | `terminal/*` callbacks | +| **Cancel** | SIGKILL | `connection.cancel()` (graceful) | + +### 8.3 Что ACP НЕ решает (нам всё ещё нужно) + +1. **Agent Teams orchestration** — ACP это one-agent ↔ one-client. Оркестрация команд, TaskCreate, SendMessage, TeamCreate — всё это наш domain logic поверх CLI-specific протокола. + +2. **stream-json специфика Claude Code** — Claude Code НЕ поддерживает `--acp` нативно. Он использует `--input-format stream-json --output-format stream-json`. ACP требует адаптер (`@zed-industries/claude-code-acp`), который внутри использует Claude Agent SDK. + +3. **Team file monitoring** — Наш `TeamConfigReader`, `TeamTaskReader`, `TeamInboxReader` мониторят файлы на диске. ACP не имеет concept of teams/tasks. + +4. **Cross-team communication** — Наш `cross_team_send`, inbox relay, sentinel messages — всё это специфика нашей архитектуры. + +5. **Post-compact context recovery** — Наши `pendingPostCompactReminder` и context reinjection — domain-specific. + +6. **Member spawn management** — Трекинг `MemberSpawnStatus`, reconnect, stall detection — наш код. + +7. **MCP config building** — `TeamMcpConfigBuilder` — наш код для сборки MCP конфигов. + +8. **Tool approval auto-resolve** — `shouldAutoAllow()` и custom rules — наша логика. + +### 8.4 Гипотетическая интеграция (Pseudocode) + +```typescript +// === ВАРИАНТ A: ACP для нового multi-agent клиента === +// Если бы Claude Code поддерживал --acp нативно + +import * as acp from '@agentclientprotocol/sdk'; +import { spawnCli } from '@main/utils/childProcess'; + +class TeamAgentClient implements acp.Client { + constructor( + private teamName: string, + private memberName: string, + private onUpdate: (event: SessionUpdate) => void, + private onPermission: (request: ToolApprovalRequest) => Promise, + ) {} + + async requestPermission(params: acp.RequestPermissionRequest) { + // Проксируем в наш UI через существующий tool approval flow + const approval = await this.onPermission(mapToOurFormat(params)); + return mapToAcpResponse(approval); + } + + async sessionUpdate(params: acp.SessionNotification) { + // Маппим ACP events в наши TeamChangeEvent'ы + const update = params.update; + switch (update.sessionUpdate) { + case 'agent_message_chunk': + this.onUpdate({ type: 'agent-text', text: update.content.text }); + break; + case 'tool_call': + this.onUpdate({ type: 'tool-call', ...mapToolCall(update) }); + break; + case 'plan': + this.onUpdate({ type: 'plan-update', entries: update.entries }); + break; + } + } +} + +async function spawnAgentWithAcp(claudePath: string, args: string[], cwd: string) { + // 1. Spawn process (наш существующий код) + const child = spawnCli(claudePath, ['--acp', ...args], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + + // 2. Create ACP connection (заменяет весь ручной NDJSON парсинг) + const input = Writable.toWeb(child.stdin!); + const output = Readable.toWeb(child.stdout!) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + + const client = new TeamAgentClient(teamName, memberName, onUpdate, onPermission); + const connection = new acp.ClientSideConnection((_agent) => client, stream); + + // 3. Initialize + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: { create: true, output: true, kill: true }, + }, + }); + + // 4. Create session with MCP servers + const mcpConfigPath = await mcpBuilder.writeConfigFile(); + const session = await connection.newSession({ + cwd, + mcpServers: [ + { type: 'stdio', command: 'node', args: [mcpServerPath] }, + ], + }); + + // 5. Send prompt + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: 'text', text: provisioningPrompt }], + }); + + // 6. Graceful cancel instead of SIGKILL + await connection.cancel({ sessionId: session.sessionId }); + + return { connection, session, child }; +} +``` + +```typescript +// === ВАРИАНТ B: ACP как дополнительный протокол (реалистичный) === +// Claude Code -> stream-json (как сейчас) +// Другие агенты (Gemini, Codex, Copilot) -> ACP +// Наше приложение поддерживает ОБА протокола + +interface AgentConnection { + sendPrompt(text: string): Promise; + onMessage(callback: (msg: AgentMessage) => void): void; + cancel(): Promise; + close(): void; +} + +class StreamJsonConnection implements AgentConnection { + // Существующий код из TeamProvisioningService + // stream-json протокол Claude Code +} + +class AcpConnection implements AgentConnection { + private connection: acp.ClientSideConnection; + private sessionId: string; + + constructor(connection: acp.ClientSideConnection, sessionId: string) { + this.connection = connection; + this.sessionId = sessionId; + } + + async sendPrompt(text: string) { + await this.connection.prompt({ + sessionId: this.sessionId, + prompt: [{ type: 'text', text }], + }); + } + + async cancel() { + await this.connection.cancel({ sessionId: this.sessionId }); + } +} + +function createAgentConnection(agent: AgentType, child: ChildProcess): AgentConnection { + if (agent === 'claude-code') { + return new StreamJsonConnection(child); // Как сейчас + } + // Gemini CLI, Codex CLI, Copilot CLI и др. + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin!), + Readable.toWeb(child.stdout!) as ReadableStream, + ); + const conn = new acp.ClientSideConnection((_agent) => new AcpClient(), stream); + return new AcpConnection(conn, sessionId); +} +``` + +### 8.5 Ключевые технические проблемы интеграции + +#### Проблема 1: Web Streams vs Node Streams +ACP SDK использует Web Streams API (`ReadableStream`, `WritableStream`). Node.js child_process возвращает Node Streams. Нужна конвертация: +```typescript +const input = Writable.toWeb(child.stdin!); // node:stream → web stream +const output = Readable.toWeb(child.stdout!); // node:stream → web stream +``` +В Electron 40.x (Node 22+) эти конвертации доступны нативно. + +#### Проблема 2: Claude Code не поддерживает ACP +Claude Code использует `stream-json`, не ACP. Для ACP нужен `@zed-industries/claude-code-acp` адаптер (который в свою очередь использует Claude Agent SDK — отдельный npm пакет с Anthropic API key). + +**Наш текущий подход (прямой CLI)** не требует API key — используется auth token пользователя. Адаптер `claude-code-acp` требует `ANTHROPIC_API_KEY`, что делает его непрактичным для нашего zero-config подхода. + +#### Проблема 3: Blocking prompt() +`connection.prompt()` блокирует до завершения turn'а. Streaming идёт через callback'и (`sessionUpdate`). Это отличается от нашего подхода где мы парсим stdout строку за строкой. + +#### Проблема 4: Team orchestration +ACP — это 1:1 (один клиент, один агент). У нас N агентов в команде. Каждый агент = отдельный ACP connection. Координация между ними — полностью наш код. + +--- + +## 9. Что код мы СОХРАНЯЕМ vs что ACP заменяет + +### Сохраняем (наш domain logic): + +| Файл/Модуль | Причина | +|-------------|---------| +| `TeamProvisioningService.ts` (80%) | Team orchestration, member management, task tracking | +| `TeamConfigReader.ts` | File-based team config monitoring | +| `TeamTaskReader.ts` | File-based task monitoring | +| `TeamInboxReader.ts` | File-based inbox monitoring | +| `TeamMcpConfigBuilder.ts` | MCP config generation | +| `TeamMembersMetaStore.ts` | Member metadata | +| `TeamSentMessagesStore.ts` | Sent messages tracking | +| `ClaudeBinaryResolver.ts` | CLI binary resolution | +| `childProcess.ts` | Process spawning (spawnCli, killProcessTree) | +| `toolApprovalRules.ts` | Auto-approval logic | +| `actionModeInstructions.ts` | Agent instructions | +| Cross-team communication | Inbox relay, sentinel messages | +| Post-compact recovery | Context reinjection | +| Stall detection | Watchdog timers | +| Auth retry | Re-spawn on auth failure | + +### ACP заменяет (если бы Claude Code поддерживал): + +| Компонент | Строки кода | Чем заменяет | +|-----------|-------------|--------------| +| NDJSON парсинг stdout | ~200 LOC | `ndJsonStream()` + `ClientSideConnection` | +| Carry buffer логика | ~50 LOC | Автоматически в SDK | +| Message type dispatching | ~150 LOC | Typed `sessionUpdate()` callback | +| Tool approval protocol | ~100 LOC | `requestPermission()` callback | +| Session init handshake | ~30 LOC | `initialize()` + `newSession()` | +| **Итого** | **~530 LOC** | Типизированный SDK | + +**Из ~6000 LOC TeamProvisioningService**, ACP заменяет ~530 LOC (менее 9%). Остальные 91% — domain-specific orchestration. + +--- + +## 10. Честные оценки + +### Сложность интеграции: 6/10 (Уверенность: 8/10) + +- SDK сам по себе простой (0 dependencies, чистый API) +- Проблема: Claude Code не поддерживает ACP нативно +- Нужен маппинг между ACP events и нашими internal types +- Web Streams конвертация в Electron — тривиальна +- Основная сложность: поддержка двух протоколов (stream-json + ACP) + +### Полезность для нашего кейса: 4/10 (Уверенность: 9/10) + +- Наш primary agent (Claude Code) НЕ поддерживает ACP +- 91% нашего кода — domain-specific, ACP не касается +- Выгода: если хотим поддержать ДРУГИЕ агенты (Gemini, Codex, Copilot) — тогда ACP становится очень полезным (8/10) +- Для Claude Code only — бессмысленно, мы уже общаемся напрямую через stream-json + +### Зрелость/стабильность: 5/10 (Уверенность: 7/10) + +- Pre-1.0 (v0.16.1) +- Много `unstable_` методов +- Breaking changes между минорами возможны +- НО: 31 контрибьютор, 544 коммита, JetBrains + Zed backing +- Активная разработка, быстрый темп (18 npm versions) +- Usage updates и session management — ещё в draft + +### Риск adoption: 5/10 (Уверенность: 7/10) + +- Zero dependencies — безопасно для bundle size +- Pre-1.0 → API может измениться +- Claude Code может получить нативную ACP поддержку в будущем (Feature Request существует) +- VS Code не поддерживает ACP — это риск для всей экосистемы +- JetBrains backing — сильный сигнал стабильности + +--- + +## 11. Рекомендация + +### WAIT — Не интегрировать сейчас. Наблюдать. + +**Надёжность решения: 8/10. Уверенность в рекомендации: 9/10.** + +**Почему WAIT, а не ADOPT:** + +1. **Claude Code — наш primary agent и он НЕ говорит по ACP.** Пока Anthropic не добавит `--acp` флаг (или не поменяет `stream-json` на ACP), интеграция ACP не даёт value для Claude Code. + +2. **Мы заменим менее 9% кода.** ROI не оправдывает migration effort + поддержку двух протоколов. + +3. **Pre-1.0 API.** Breaking changes реальны. Лучше подождать стабилизации. + +**Когда стоит ADOPT:** + +1. **Claude Code получит нативную ACP поддержку** — тогда можно мигрировать stream-json → ACP, упростив парсинг. + +2. **Мы решим поддержать multi-agent (Gemini + Codex + Claude)** — тогда ACP станет единым протоколом для не-Claude агентов. Архитектура: stream-json для Claude, ACP для остальных, общий `AgentConnection` интерфейс. + +3. **ACP достигнет 1.0** — стабильный API, можно инвестировать в интеграцию. + +**Что делать прямо сейчас:** + +1. Следить за [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686) (Claude Code ACP support) +2. Следить за [ACP Updates](https://agentclientprotocol.com/updates) (protocol evolution) +3. Проектировать `AgentConnection` abstraction в нашем коде, чтобы stream-json и ACP могли быть взаимозаменяемы в будущем +4. Если решим поддержать Gemini/Codex — начать с ACP как протокола для них + +--- + +## Приложение A: Полная архитектура ACP Protocol Schema + +### Error Codes (JSON-RPC) + +| Code | Meaning | +|------|---------| +| -32700 | Parse error | +| -32600 | Invalid request | +| -32601 | Method not found | +| -32602 | Invalid params | +| -32603 | Internal error | +| -32000 | Authentication required | +| -32002 | Resource not found | + +### Permission Option Kinds + +| Kind | Описание | +|------|----------| +| `allow_once` | Разрешить один раз | +| `allow_always` | Разрешить всегда | +| `reject_once` | Отклонить один раз | +| `reject_always` | Отклонить всегда | + +### Stop Reasons + +| Reason | Описание | +|--------|----------| +| `end_turn` | Агент завершил turn нормально | +| `cancelled` | Пользователь отменил | +| `max_tokens` | Достигнут лимит токенов | +| `tool_use` | Агент ожидает результат tool (редко в ACP) | + +### Tool Call Kinds + +| Kind | Описание | +|------|----------| +| `read` | Чтение (файл, поиск) | +| `edit` | Редактирование файла | +| `command` | Выполнение команды | +| `tool` | Вызов MCP tool | + +### Diff Format + +```json +{ + "path": "/absolute/path/to/file.ts", + "oldText": "original content (null for new files)", + "newText": "modified content" +} +``` + +--- + +## Приложение B: Ссылки + +### Спецификация и документация +- [ACP Introduction](https://agentclientprotocol.com/get-started/introduction) +- [ACP Protocol Overview](https://agentclientprotocol.com/protocol/overview) +- [ACP Schema](https://agentclientprotocol.com/protocol/schema) +- [ACP Updates](https://agentclientprotocol.com/updates) +- [ACP Registry](https://agentclientprotocol.com/registry) + +### SDK +- [npm: @agentclientprotocol/sdk](https://www.npmjs.com/package/@agentclientprotocol/sdk) +- [GitHub: typescript-sdk](https://github.com/agentclientprotocol/typescript-sdk) +- [API Reference](https://agentclientprotocol.github.io/typescript-sdk/) +- [SDK Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples) + +### Claude Code ACP +- [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686) +- [Zed Claude Code ACP Adapter](https://github.com/zed-industries/claude-agent-acp) +- [Zed Blog: Claude Code via ACP](https://zed.dev/blog/claude-code-via-acp) + +### Ecosystem +- [Zed ACP](https://zed.dev/acp) +- [JetBrains ACP](https://www.jetbrains.com/acp/) +- [JetBrains ACP Docs](https://www.jetbrains.com/help/ai-assistant/acp.html) +- [GitHub Copilot ACP](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) +- [Goose ACP](https://block.github.io/goose/blog/2025/10/24/intro-to-agent-client-protocol-acp/) +- [Kiro CLI ACP](https://kiro.dev/docs/cli/acp/) +- [OpenCode ACP](https://opencode.ai/docs/acp/) diff --git a/docs/research/agent-spawn-packages.md b/docs/research/agent-spawn-packages.md new file mode 100644 index 00000000..d1241994 --- /dev/null +++ b/docs/research/agent-spawn-packages.md @@ -0,0 +1,465 @@ +# Agent Spawn Packages — Deep Dive Research + +**Дата:** 2026-03-25 +**Цель:** Найти лучший способ программно запускать CLI-агентов (Claude Code, Codex, Gemini CLI) из Electron-приложения. + +--- + +## TL;DR — Итоговая Рекомендация + +У всех трёх главных CLI-агентов **теперь есть ОФИЦИАЛЬНЫЕ SDK** для программного запуска: + +| Агент | SDK Пакет | Лицензия | Зрелость | +|-------|-----------|----------|----------| +| Claude Code | `@anthropic-ai/claude-agent-sdk` | **Proprietary** (Commercial ToS) | Stable (v0.2.83) | +| Codex | `@openai/codex-sdk` | **Apache-2.0** | Stable (v0.116.0) | +| Gemini CLI | `@google/gemini-cli-sdk` + `@google/gemini-cli-core` | **Apache-2.0** | Early (v0.30.0+) | + +**Вывод:** Вместо форка `@swarmify/agents-mcp` или написания своих spawn-обёрток, лучше использовать **официальные SDK** от каждого провайдера. Они более надёжны, поддерживаются, и дают нативный доступ без хрупкого парсинга stdout. + +--- + +## 1. @swarmify/agents-mcp + +**npm:** https://www.npmjs.com/package/@swarmify/agents-mcp +**Сайт:** https://swarmify.co/ +**GitHub:** НЕ НАЙДЕН (closed-source или приватный репозиторий) + +### Что это +MCP-сервер, который позволяет любому MCP-клиенту (Claude, Codex, Gemini, Cursor) спавнить параллельных агентов. Часть экосистемы Swarmify. + +### Предоставляет +- **4 MCP-тула:** Spawn, Status, Stop, Tasks +- **3 режима:** plan (read-only), edit (can write), ralph (autonomous) +- Фоновые процессы — агенты переживают перезапуск IDE +- Авто-детект Claude, Codex, Gemini CLI при установке + +### Как спавнит агентов +Агенты коммуницируют через файловую систему — каждый агент пишет в свой лог-файл (stdout.log). Тул Status читает эти логи, нормализует события между разными форматами агентов, и возвращает сводку. + +### IAgent / BaseAgent — НЕ НАЙДЕНЫ +Несмотря на множество поисков, интерфейсы `IAgent` и `BaseAgent` **не обнаружены** в публичной документации пакета. Возможно, они существуют внутри скомпилированного npm-пакета (можно проверить через `node_modules`), но исходный код закрыт. + +### Оценка для нас +- **Надёжность: 3/10** — Closed-source, нет GitHub, невозможно форкнуть +- **Уверенность: 2/10** — Без исходного кода невозможно оценить качество +- **Вердикт:** НЕ подходит для нашего проекта + +--- + +## 2. @anthropic-ai/claude-agent-sdk (OFFICIAL) + +**npm:** https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk +**GitHub:** https://github.com/anthropics/claude-agent-sdk-typescript +**Docs:** https://platform.claude.com/docs/en/agent-sdk/typescript +**Версия:** 0.2.83 (25 марта 2026) +**Лицензия:** Proprietary (Anthropic Commercial Terms of Service) +**Звёзды:** ~1000 | **Форки:** ~117 | **Релизы:** 67 +**691 проект** в npm registry используют этот пакет + +### Что это +Официальный SDK от Anthropic для программного запуска Claude Code. Переименован из "Claude Code SDK" в "Claude Agent SDK". Даёт те же инструменты, agent loop и context management, что и Claude Code. + +### Ключевой API + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +// Основная функция — async generator +const q = query({ + prompt: "Fix the bug in auth.py", + options: { + model: "opus", + cwd: "/path/to/project", + allowedTools: ["Read", "Edit", "Bash"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 50, + maxBudgetUsd: 5.0, + env: { ANTHROPIC_API_KEY: "..." }, + mcpServers: { /* MCP config */ }, + agents: { + // Программно определяемые субагенты + reviewer: { + description: "Code reviewer agent", + prompt: "Review code for bugs", + model: "sonnet", + tools: ["Read", "Grep", "Glob"], + } + }, + settingSources: ["project"], // Загрузка CLAUDE.md + thinking: { type: "adaptive" }, + } +}); + +// Стриминг событий +for await (const message of q) { + // SDKMessage types: assistant, user, result, system, etc. + console.log(message); +} + +// Query object methods: +// q.interrupt(), q.close(), q.setModel(), q.mcpServerStatus() +// q.initializationResult(), q.supportedModels(), q.supportedAgents() +``` + +### Как спавнит Claude Code +SDK **запускает Claude Code CLI как subprocess** — НЕ чисто API-библиотека. Каждый вызов `query()` спавнит новый процесс (~12 сек overhead). + +Ключевые опции: +- `spawnClaudeCodeProcess` — кастомная функция для запуска процесса (VMs, Docker, remote) +- `pathToClaudeCodeExecutable` — путь к бинарнику Claude Code +- `env` — переменные окружения для subprocess (полезно для Electron) +- `executable` — runtime: `'node'`, `'bun'`, `'deno'` + +### Session Management +```typescript +import { listSessions, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; + +const sessions = await listSessions({ dir: "/path/to/project", limit: 10 }); +const messages = await getSessionMessages(sessionId, { limit: 20 }); + +// Resume session +const q = query({ + prompt: "Continue working", + options: { resume: sessionId } +}); +``` + +### V2 Preview API (упрощённый интерфейс) +Новый API с `send()` и `stream()` паттернами для multi-turn conversations. + +### Субагенты +Определяются программно через `agents` option в `AgentDefinition`: +```typescript +type AgentDefinition = { + description: string; + prompt: string; + tools?: string[]; + disallowedTools?: string[]; + model?: "sonnet" | "opus" | "haiku" | "inherit"; + mcpServers?: AgentMcpServerSpec[]; + skills?: string[]; + maxTurns?: number; +}; +``` + +### MCP-серверы +Поддерживает in-process MCP серверы: +```typescript +import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +const server = createSdkMcpServer({ + name: "my-server", + tools: [ + tool("search", "Search the web", { query: z.string() }, async ({ query }) => { + return { content: [{ type: "text", text: `Results for: ${query}` }] }; + }) + ] +}); +``` + +### Ограничения лицензии +- **Proprietary** — НЕ open-source +- Запрещено использовать OAuth токены Claude Free/Pro/Max — нужен **API key** +- Продукт должен иметь **собственный брендинг** (не Claude Code) +- Anthropic собирает telemetry (usage, feedback, conversations) + +### Оценка для нас +- **Надёжность: 9/10** — Официальный SDK от Anthropic, активно развивается +- **Уверенность: 9/10** — Отлично документирован, 691+ пользователь +- **Риск:** Proprietary лицензия, ~12 сек overhead на query(), зависимость от CLI binary + +--- + +## 3. @openai/codex-sdk (OFFICIAL) + +**npm:** https://www.npmjs.com/package/@openai/codex-sdk +**GitHub:** https://github.com/openai/codex/tree/main/sdk/typescript +**Docs:** https://developers.openai.com/codex/sdk +**Версия:** 0.116.0 +**Лицензия:** Apache-2.0 +**107 проектов** в npm registry используют этот пакет + +### Что это +Официальный TypeScript SDK от OpenAI для программного управления Codex CLI. Оборачивает CLI, обменивается JSONL-событиями через stdin/stdout. + +### Ключевой API + +```typescript +import { Codex } from "@openai/codex-sdk"; + +// Инициализация +const codex = new Codex({ + env: { PATH: "/usr/local/bin" }, // Полезно для Electron + config: { + show_raw_agent_reasoning: true, + sandbox_workspace_write: { network_access: true } + }, + baseUrl: "https://api.example.com" // Optional +}); + +// Thread management +const thread = codex.startThread({ + workingDirectory: "/path/to/project", + skipGitRepoCheck: true // Для non-git environments +}); + +// Buffered response +const turn = await thread.run("Fix the test failure"); +console.log(turn.finalResponse); +console.log(turn.items); + +// Streaming response +const { events } = await thread.runStreamed("Diagnose failures"); +for await (const event of events) { + switch (event.type) { + case "item.completed": console.log("Item:", event.item); break; + case "turn.completed": console.log("Usage:", event.usage); break; + } +} + +// Multi-turn conversations +const turn1 = await thread.run("Diagnose issue"); +const turn2 = await thread.run("Implement the fix"); + +// Resume persisted thread +const thread2 = codex.resumeThread(process.env.CODEX_THREAD_ID!); +``` + +### Как спавнит Codex CLI +SDK спавнит **Codex CLI** (Rust-based `@openai/codex`) как subprocess и обменивается JSONL-событиями через stdin/stdout. + +- Работает **ТОЛЬКО** с Native (Rust) версией Codex +- SDK инжектит `CODEX_API_KEY` поверх переданных env variables +- `env` параметр — полный контроль над переменными (полезно для Electron) +- `config` — JSON → dotted paths → TOML literals → `--config key=value` flags + +### Session Persistence +- Threads сохраняются в `~/.codex/sessions` +- `resumeThread(id)` — восстановление потерянного Thread + +### Structured Output +```typescript +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] } + }, + required: ["summary", "status"], + additionalProperties: false +} as const; + +const turn = await thread.run("Summarize status", { outputSchema: schema }); +``` + +### Multi-Agent Collaboration +Поддержка spawn_agent, send_input, wait для координации между threads. + +### Оценка для нас +- **Надёжность: 9/10** — Официальный SDK от OpenAI, Apache-2.0 +- **Уверенность: 8/10** — Хорошо документирован, активно развивается +- **Риск:** Только Rust-based Codex, зависимость от Git repo (опционально отключается) + +--- + +## 4. @google/gemini-cli-sdk + @google/gemini-cli-core (OFFICIAL) + +**npm (CLI):** https://www.npmjs.com/package/@google/gemini-cli +**npm (Core):** https://www.npmjs.com/package/@google/gemini-cli-core +**GitHub:** https://github.com/google-gemini/gemini-cli +**Docs:** https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api +**Версия:** SDK появился в v0.30.0 (2026-02-25) +**Лицензия:** Apache-2.0 +**Звёзды:** ~99K + +### Что это +Официальный SDK от Google для программного запуска Gemini CLI. Монорепо-архитектура. + +### Архитектура пакетов +| Пакет | Роль | +|-------|------| +| `@google/gemini-cli-sdk` | Consumer-facing API | +| `@google/gemini-cli-core` | Core orchestration, tools, API | +| `@google/gemini-cli` | Terminal reference implementation | + +### Ключевой API + +```typescript +import { LocalAgentExecutor, LocalAgentDefinition } from '@google/gemini-cli-core'; + +const agentDef: LocalAgentDefinition = { + modelId: 'gemini-2.0-flash', + systemPrompt: 'You are a helpful assistant', + tools: ['read_file', 'write_file', 'run_shell_command'], + maxTurns: 30, + timeoutMs: 600000 // 10 min +}; + +const executor = new LocalAgentExecutor(config, agentDef); + +// Activity monitoring +executor.onActivity((event) => { + console.log('Agent activity:', event); +}); + +const result = await executor.run({ + task: 'Analyze the codebase and suggest improvements' +}); + +console.log('Termination mode:', result.terminateMode); // GOAL | MAX_TURNS | TIMEOUT +console.log('Result:', result.output); +``` + +### Как спавнит Gemini +В отличие от Claude и Codex, SDK Gemini — **НЕ CLI wrapper**, а **нативная библиотека**. Использует core-логику напрямую: +- `GeminiCliAgent` / `LocalAgentExecutor` — primary entity +- Каждый агент получает свой `ToolRegistry` (изоляция) +- `MessageBus` для async events (tool confirmations) +- `Config` class для model selection и auth + +### Tool Management +- Built-in tools (file system, shell, web) +- MCP server tools +- Extension-provided tools +- Tool confirmation через `TOOL_CONFIRMATION_REQUEST` event + +### Agent Termination +```typescript +enum AgentTerminateMode { + GOAL, // Успешно вызвал complete_task + MAX_TURNS, // Достиг лимита (default 30) + TIMEOUT // Превысил время (default 10 min) +} +``` + +### Headless Mode (альтернатива) +```bash +gemini --output-format json -p "Summarize project" +gemini --output-format stream-json -p "Fix bug" +``` + +### Зрелость +- SDK появился в v0.30.0 (2026-02-25) — **очень свежий** +- Feature request #15539 (Dec 2025) формально запрашивал SDK +- API может меняться + +### Оценка для нас +- **Надёжность: 6/10** — Apache-2.0, открытый код, но SDK совсем новый +- **Уверенность: 6/10** — API может меняться, документация неполная +- **Преимущество:** Нативная библиотека (не CLI wrapper), лучшая производительность + +--- + +## 5. Альтернативные Multi-Agent Frameworks + +### jayminwest/overstory +**GitHub:** https://github.com/jayminwest/overstory +**Лицензия:** MIT + +Pluggable `AgentRuntime` интерфейс с **11 адаптерами** (Claude Code, Codex, Gemini CLI, Aider, Goose, Amp и др). Агенты работают в изолированных git worktrees через tmux. + +| Runtime | CLI | Guard Mechanism | +|---------|-----|-----------------| +| Claude Code | `claude` | `settings.local.json` hooks | +| Codex | `codex` | OS-level sandbox | +| Gemini | `gemini` | `--sandbox` flag | +| Aider | `aider` | None (`--yes-always`) | +| Goose | `goose` | Profile-based permissions | +| + 6 others | ... | ... | + +**Интересно, но:** Ориентирован на CLI/tmux workflow, не на Electron SDK. + +### desplega-ai/agent-swarm +**GitHub:** https://github.com/desplega-ai/agent-swarm +**Docs:** https://docs.agent-swarm.dev/ + +Lead-worker паттерн с Docker-изоляцией. Поддерживает Claude Code, с планами на Codex/Gemini. SQLite + bun. + +--- + +## 6. Сравнительная Таблица SDK + +| | Claude Agent SDK | Codex SDK | Gemini CLI SDK | +|---|---|---|---| +| **Пакет** | `@anthropic-ai/claude-agent-sdk` | `@openai/codex-sdk` | `@google/gemini-cli-sdk` | +| **Версия** | 0.2.83 | 0.116.0 | ~0.30.0+ | +| **Лицензия** | Proprietary | Apache-2.0 | Apache-2.0 | +| **Архитектура** | CLI subprocess | CLI subprocess (Rust) | Нативная библиотека | +| **Стриминг** | AsyncGenerator | AsyncGenerator (events) | onActivity callback | +| **Session Resume** | Да (sessionId) | Да (resumeThread) | Да (SessionContext) | +| **Субагенты** | Да (agents option) | Да (spawn_agent) | Да (LocalAgentDefinition) | +| **MCP серверы** | Да (in-process + external) | Нет (native tools only) | Да (ToolRegistry) | +| **Custom Env** | Да (env option) | Да (env option) | Да (Config) | +| **Custom Spawn** | Да (spawnClaudeCodeProcess) | Нет | Нет (нативная) | +| **Structured Output** | Да (JSON Schema) | Да (JSON Schema + Zod) | Да (zod OutputConfig) | +| **Node.js** | 18+ | 18+ | 18+ | +| **Overhead** | ~12s per query() | Не измерен | Минимальный (нативная) | +| **npm Users** | 691 | 107 | N/A (новый) | + +--- + +## 7. Рекомендация для Claude Agent Teams UI + +### Основной подход (Recommended) +**Использовать официальные SDK каждого провайдера** вместо единого абстрактного слоя. + +``` +src/main/services/agents/ +├── types.ts # Общие типы (AgentProcess, AgentEvent, AgentConfig) +├── claude-adapter.ts # Обёртка над @anthropic-ai/claude-agent-sdk +├── codex-adapter.ts # Обёртка над @openai/codex-sdk +├── gemini-adapter.ts # Обёртка над @google/gemini-cli-sdk +└── agent-registry.ts # Реестр доступных агентов +``` + +Тонкий адаптерный слой (~100-150 LOC на адаптер) над каждым SDK, нормализующий: +- Стриминг событий → единый `AgentEvent` формат +- Session management → единый `AgentSession` интерфейс +- Process lifecycle → start/stop/status + +### Почему НЕ @swarmify/agents-mcp +1. Closed-source — невозможно аудировать или форкнуть +2. MCP-only интерфейс — мы уже имеем прямой доступ к процессам +3. Filesystem-based communication — избыточный overhead для Electron + +### Почему НЕ единый CLI spawn +1. Все 3 провайдера выпустили свои SDK +2. SDK дают типизированные события, session management, structured output +3. Raw CLI spawn хрупок (парсинг stdout/ANSI codes) + +### Почему НЕ overstory AgentRuntime +1. Ориентирован на tmux/worktree workflow +2. MIT лицензия хорошая, но архитектура не подходит для Electron +3. 11 адаптеров — избыточно, нам нужны 3 + +### Порядок интеграции +1. **Claude Code** (`@anthropic-ai/claude-agent-sdk`) — у нас уже есть, нужно мигрировать на SDK +2. **Codex** (`@openai/codex-sdk`) — Apache-2.0, простой API, thread-based +3. **Gemini** (`@google/gemini-cli-sdk`) — подождать стабилизации API (SDK очень свежий) + +### Риски +- **Claude SDK Proprietary лицензия** — нужно проверить совместимость с нашим MIT +- **~12s overhead** Claude SDK per query — может потребоваться process pooling +- **Gemini SDK API unstable** — может сломаться в любом релизе + +--- + +## Источники + +- [@swarmify/agents-mcp (npm)](https://www.npmjs.com/package/@swarmify/agents-mcp) +- [Swarmify](https://swarmify.co/) +- [@anthropic-ai/claude-agent-sdk (npm)](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) +- [Claude Agent SDK TypeScript (GitHub)](https://github.com/anthropics/claude-agent-sdk-typescript) +- [Claude Agent SDK Reference](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Run Claude Code programmatically](https://code.claude.com/docs/en/headless) +- [@openai/codex-sdk (npm)](https://www.npmjs.com/package/@openai/codex-sdk) +- [Codex SDK TypeScript (GitHub)](https://github.com/openai/codex/tree/main/sdk/typescript) +- [Codex SDK Docs](https://developers.openai.com/codex/sdk) +- [@google/gemini-cli (GitHub)](https://github.com/google-gemini/gemini-cli) +- [Gemini CLI SDK (DeepWiki)](https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api) +- [Gemini CLI SDK Feature Request #15539](https://github.com/google-gemini/gemini-cli/issues/15539) +- [overstory (GitHub)](https://github.com/jayminwest/overstory) +- [agent-swarm (GitHub)](https://github.com/desplega-ai/agent-swarm) diff --git a/docs/research/ai-agent-protocols-and-routing.md b/docs/research/ai-agent-protocols-and-routing.md new file mode 100644 index 00000000..1365dc84 --- /dev/null +++ b/docs/research/ai-agent-protocols-and-routing.md @@ -0,0 +1,782 @@ +# AI Agent Orchestration Landscape: Protocols, Routing & Desktop Tools + +**Date:** March 24, 2026 +**Status:** Research snapshot (rapidly evolving landscape) + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Protocol-Level Standards](#1-protocol-level-standards) + - [MCP (Model Context Protocol)](#11-mcp--model-context-protocol) + - [A2A (Agent2Agent Protocol)](#12-a2a--agent2agent-protocol) + - [ACP (Agent Communication Protocol)](#13-acp--agent-communication-protocol) + - [AGENTS.md](#14-agentsmd) + - [Protocol Layer Summary](#15-protocol-layer-summary) +3. [Governance: Agentic AI Foundation (AAIF)](#2-governance-agentic-ai-foundation-aaif) +4. [Multi-Model Routing & Proxy Tools](#3-multi-model-routing--proxy-tools) + - [LiteLLM](#31-litellm) + - [OpenRouter](#32-openrouter) +5. [Agent Orchestration Frameworks](#4-agent-orchestration-frameworks) + - [LangGraph](#41-langgraph) + - [CrewAI](#42-crewai) + - [AutoGen / Microsoft Agent Framework](#43-autogen--microsoft-agent-framework) + - [OpenAI Agents SDK](#44-openai-agents-sdk) + - [Google Agent Development Kit (ADK)](#45-google-agent-development-kit-adk) + - [AWS Strands Agents](#46-aws-strands-agents) + - [OpenAgents](#47-openagents) + - [GitAgent](#48-gitagent) + - [Goose (Block)](#49-goose-block) + - [Framework Comparison Table](#410-framework-comparison-table) +6. [Desktop/Local Orchestration Tools](#5-desktoplocal-orchestration-tools) + - [VS Code Multi-Agent Hub](#51-vs-code-multi-agent-hub) + - [Augment Code Intent](#52-augment-code-intent) + - [OpenAI Codex Desktop App](#53-openai-codex-desktop-app) +7. [Relevance for Claude Agent Teams UI](#6-relevance-for-claude-agent-teams-ui) +8. [Sources](#sources) + +--- + +## Executive Summary + +As of March 2026, the AI agent ecosystem has consolidated around three complementary protocol layers: + +| Layer | Protocol | Purpose | Governance | +|-------|----------|---------|------------| +| **Agent-to-Tool** | MCP | Connect agents to tools/data | AAIF (Linux Foundation) | +| **Agent-to-Agent** | A2A | Agents discover/communicate with each other | Linux Foundation | +| **Agent Config** | AGENTS.md | Project-level agent instructions | AAIF (Linux Foundation) | + +All three are open-source, vendor-neutral, and governed by the Linux Foundation. The Agentic AI Foundation (AAIF), co-founded by Anthropic, OpenAI, and Block in December 2025, is the umbrella organization. + +Key numbers: +- **MCP:** 97M monthly SDK downloads, 10,000+ servers, 300+ clients +- **A2A:** 22.7K GitHub stars, 150+ supporting organizations, v0.3 released +- **AGENTS.md:** Adopted by 60,000+ open-source projects, supported by all major coding agents except Claude Code + +The framework landscape is fragmenting into three tiers: +1. **Cloud-vendor SDKs** (OpenAI Agents SDK, Google ADK, AWS Strands, Microsoft Agent Framework) -- production-grade, tied to ecosystems +2. **Independent frameworks** (LangGraph, CrewAI, OpenAgents) -- model-agnostic, community-driven +3. **Portability layers** (GitAgent, MCP, A2A) -- cross-framework interop + +Desktop orchestration is emerging as a new category, with VS Code, Augment Intent, and OpenAI Codex App leading the charge. + +--- + +## 1. Protocol-Level Standards + +### 1.1 MCP -- Model Context Protocol + +| Field | Value | +|-------|-------| +| **URL** | [modelcontextprotocol.io](https://modelcontextprotocol.io/) | +| **GitHub** | [modelcontextprotocol](https://github.com/modelcontextprotocol) | +| **Created by** | Anthropic (November 2024) | +| **Governance** | AAIF / Linux Foundation (donated December 2025) | +| **License** | Apache 2.0 | +| **Maturity** | Production -- spec version 2025-11-25 | +| **Adoption** | 97M monthly SDK downloads, 10,000+ servers, 300+ clients | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it enables:** Standardized agent-to-tool communication. Any AI model can connect to any data source or tool through a universal interface (tools, resources, prompts). Often compared to "USB-C for AI." + +**Key facts:** +- Adopted by every major AI platform: Claude, ChatGPT, Cursor, Gemini, Microsoft Copilot, VS Code +- OpenAI adopted MCP across its products in March 2025 +- 2026 roadmap focuses on: transport scalability (remote servers), agent communication upgrades (chunked messages, multipart streams), enterprise readiness (audit trails, SSO) +- Security concerns: prompt injection, tool poisoning, cross-server shadowing identified in April 2025 analysis + +**Relation to A2A:** MCP handles agent-to-tool connections. A2A handles agent-to-agent. Complementary, not competing. A common production pattern: MCP for tool connections + A2A for agent coordination. + +> Source: [A Year of MCP (Pento)](https://www.pento.ai/blog/a-year-of-mcp-2025-review), [The 2026 MCP Roadmap](http://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/), [MCP Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol), [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25), [The New Stack - MCP 2026 Roadmap](https://thenewstack.io/model-context-protocol-roadmap-2026/) + +--- + +### 1.2 A2A -- Agent2Agent Protocol + +| Field | Value | +|-------|-------| +| **URL** | [github.com/a2aproject/A2A](https://github.com/a2aproject/A2A) | +| **Created by** | Google (April 9, 2025, Cloud Next) | +| **Governance** | Linux Foundation (June 2025) | +| **License** | Apache 2.0 | +| **Version** | 0.3 (July 2025) -- added gRPC, signed security cards | +| **GitHub Stars** | 22.7K (main repo) | +| **Supporting Orgs** | 150+ (Atlassian, Salesforce, SAP, PayPal, etc.) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it enables:** Standardized agent-to-agent communication. Agents discover each other via "Agent Cards" (JSON at `/.well-known/agent.json`), negotiate capabilities, and exchange tasks over HTTP/SSE/JSON-RPC. + +**Key features:** +- **Capability discovery** via Agent Cards (name, endpoint, skills, auth flows) +- **Flexible modalities**: text, audio, video streaming +- **Enterprise auth**: parity with OpenAPI authentication schemes +- **Supports async**: tasks from quick responses to multi-day research +- Protocol: JSON-RPC 2.0 over HTTP(S), SSE for streaming, push notifications + +**ACP merger (August 2025):** IBM's Agent Communication Protocol (ACP) officially merged into A2A under the Linux Foundation. BeeAI platform now uses A2A. + +**Ecosystem:** Native support in Google ADK, AWS Strands, Microsoft Agent Framework, LiteLLM, OpenAgents. CrewAI added A2A support. LangGraph and AutoGen have not yet adopted natively. + +> Source: [Google Developers Blog - A2A](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/), [Google Cloud Blog - A2A Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade), [Linux Foundation - A2A Project](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents), [IBM - A2A](https://www.ibm.com/think/topics/agent2agent-protocol), [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) + +--- + +### 1.3 ACP -- Agent Communication Protocol + +| Field | Value | +|-------|-------| +| **URL** | [github.com/i-am-bee/acp](https://github.com/i-am-bee/acp) | +| **Created by** | IBM BeeAI (March 2025) | +| **Status** | **Merged into A2A** (August 2025) | +| **License** | Apache 2.0 | +| **Reliability** | 7/10 (merged, not standalone) | +| **Confidence** | 8/10 | + +**What it was:** A lightweight REST-based protocol for agent-to-agent messaging. No SDK required -- curl/Postman compatible. Key differentiators were offline agent discovery and peer-to-peer interaction. + +**Current status:** ACP merged into A2A. The BeeAI platform now runs on A2A. IBM stated: "By bringing the assets and expertise behind ACP into A2A, we can build a single, more powerful standard." Migration guides are available. + +**Legacy significance:** ACP influenced A2A's design toward simpler REST-based patterns and offline discovery capabilities. + +> Source: [IBM Research - ACP](https://research.ibm.com/blog/agent-communication-protocol-ai), [IBM - What is ACP](https://www.ibm.com/think/topics/agent-communication-protocol), [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) + +--- + +### 1.4 AGENTS.md + +| Field | Value | +|-------|-------| +| **URL** | [agents.md](https://agents.md/) | +| **Created by** | OpenAI (August 2025) | +| **Governance** | AAIF / Linux Foundation | +| **License** | Open standard (Markdown convention) | +| **Adoption** | 60,000+ repositories | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**What it enables:** A standardized Markdown file that gives AI coding agents project-specific instructions (build commands, coding conventions, testing requirements, boundaries). Like `.gitignore` but for agents. + +**Adoption:** Supported by GitHub Copilot, Cursor, Windsurf, Zed, Warp, VS Code, JetBrains Junie, OpenAI Codex CLI, Google Jules, Gemini CLI, Amp, Devin, Aider, goose, RooCode, Augment Code. + +**Notable exception:** Claude Code uses its own `CLAUDE.md` format. Open issue with 3,000+ upvotes requesting AGENTS.md support, but Anthropic has not committed to it. + +**For monorepos:** Nested AGENTS.md files work (agents parse nearest file in directory tree). OpenAI's main repo has 88 AGENTS.md files. + +> Source: [InfoQ - AGENTS.md](https://www.infoq.com/news/2025/08/agents-md/), [agents.md official site](https://agents.md/), [OpenAI AAIF announcement](https://openai.com/index/agentic-ai-foundation/) + +--- + +### 1.5 Protocol Layer Summary + +``` ++--------------------------------------------------+ +| AGENTS.md / CLAUDE.md | <- Agent config/instructions ++--------------------------------------------------+ +| A2A (Agent-to-Agent Protocol) | <- Agent discovery & communication +| (includes former ACP) | ++--------------------------------------------------+ +| MCP (Model Context Protocol) | <- Agent-to-tool connections ++--------------------------------------------------+ +| HTTP / SSE / JSON-RPC / gRPC | <- Transport layer ++--------------------------------------------------+ +``` + +All three major layers are: +- Open source (Apache 2.0) +- Governed by the Linux Foundation (via AAIF or directly) +- Backed by every major AI company +- Production-ready or approaching it + +--- + +## 2. Governance: Agentic AI Foundation (AAIF) + +| Field | Value | +|-------|-------| +| **URL** | [aaif.io](https://aaif.io/) | +| **Parent** | Linux Foundation | +| **Founded** | December 9, 2025 | +| **Co-founders** | Anthropic, Block, OpenAI | +| **Platinum Members** | AWS, Anthropic, Block, Bloomberg, Cloudflare, Google, Microsoft, OpenAI | +| **Total Members** | 97+ | +| **Board Chair** | David Nalley (AWS) | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it does:** Neutral governance body for agentic AI open standards. Hosts MCP, goose, and AGENTS.md as founding projects. A2A is governed separately under the Linux Foundation but aligned. + +**Key principles:** +- Open governance: contributors from all backgrounds shape direction +- Project autonomy: individual projects maintain full technical independence +- Sustainability: neutral infrastructure and funding (not vendor-controlled) +- Focused scope: agentic AI only (not all of AI/ML/data science) + +**Funding model:** "Directed fund" -- companies contribute through membership dues. Roadmaps set by technical steering committees, not sponsors. + +**Government alignment:** NIST launched the "AI Agent Standards Initiative" in February 2026 to foster industry-led technical standards for AI agents. + +**Upcoming event:** MCP Dev Summit North America, April 2-3, 2026, New York City. + +> Source: [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation), [OpenAI - AAIF](https://openai.com/index/agentic-ai-foundation/), [Anthropic - AAIF](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation), [NIST AI Agent Standards Initiative](https://www.nist.gov/news-events/news/2026/02/announcing-ai-agent-standards-initiative-interoperable-and-secure) + +--- + +## 3. Multi-Model Routing & Proxy Tools + +### 3.1 LiteLLM + +| Field | Value | +|-------|-------| +| **URL** | [litellm.ai](https://docs.litellm.ai/) | +| **GitHub** | [BerriAI/litellm](https://github.com/BerriAI/litellm) | +| **Type** | LLM Gateway / Proxy (self-hosted) | +| **License** | MIT (Enterprise features paid) | +| **LLM Support** | 100+ models | +| **Agent Support** | A2A agents (LangGraph, Vertex AI, Azure, Bedrock, Pydantic AI) | +| **MCP Support** | Yes (central endpoint with per-key ACL) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**What it enables:** +- Unified OpenAI-compatible gateway for 100+ LLMs from all providers +- A2A agent routing through the same gateway +- MCP tool access with per-key access control +- Load balancing: simple-shuffle, least-busy, usage-based, latency-based +- Retry/fallback across deployments +- Cost tracking per key/team/user +- Content filtering, PII masking, guardrails + +**Performance:** 8ms P95 latency at 1K RPS. + +**Known issues (2025-2026):** +- Python GIL limits concurrency under high load +- DB logging degrades after 1M+ logs (GitHub issue #12067) +- Enterprise features (SSO, RBAC, budgets) locked behind paid license +- 800+ open GitHub issues; September 2025 release caused OOM on Kubernetes +- Bifrost (Go-based competitor) claims 50x faster performance + +**Agent routing capability:** LiteLLM supports adding A2A agents as first-class endpoints, meaning you can route to both LLMs and agents through the same gateway. This makes it a potential universal backend for agent orchestration. + +**Relevance for desktop agent UI:** High. Could serve as a unified backend that routes requests to different LLM providers and A2A agents through a single API. The self-hosted nature and OpenAI-compatible API make it easy to integrate. + +> Source: [LiteLLM Docs](https://docs.litellm.ai/docs/), [LiteLLM GitHub](https://github.com/BerriAI/litellm), [Top 5 LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/top-5-litellm-alternatives-in-2026/) + +--- + +### 3.2 OpenRouter + +| Field | Value | +|-------|-------| +| **URL** | [openrouter.ai](https://openrouter.ai/) | +| **Type** | Cloud-hosted LLM routing service | +| **Models** | 500+ from 60+ providers | +| **Scale** | 250K+ apps, 4.2M+ users | +| **API** | OpenAI SDK compatible | +| **License** | Proprietary (cloud service) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it enables:** +- Single API for 500+ models (OpenAI, Anthropic, Google, Meta, Mistral, etc.) +- Auto-routing: cheap models for simple queries, premium for complex +- Automatic provider fallback for reliability +- Low latency: ~15ms overhead (edge infrastructure) +- 29 free models available (no credit card) + +**Agent support:** Supports building agentic workflows through the API, but no native A2A/MCP protocol support. It is an LLM routing layer, not an agent orchestration layer. + +**Multi-model strategy for agents:** The recommended approach is to use different models for different tasks (e.g., Devstral for coding, MiniMax for agents, DeepSeek for general). OpenRouter's auto-routing facilitates this. + +**Relevance for desktop agent UI:** Medium. Excellent for LLM routing (choosing models per task), but lacks native agent orchestration. Would need to be paired with an agent framework. Not self-hostable. + +> Source: [OpenRouter](https://openrouter.ai/), [OpenRouter Review 2026](https://aiagentslist.com/agents/openrouter), [Building Agentic AI with OpenRouter](https://dev.to/allanninal/building-your-first-agentic-ai-workflow-with-openrouter-api-1fo6) + +--- + +## 4. Agent Orchestration Frameworks + +### 4.1 LangGraph + +| Field | Value | +|-------|-------| +| **GitHub** | [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) | +| **Architecture** | Graph-based workflows (nodes + edges) | +| **Languages** | Python, JavaScript/TypeScript | +| **License** | MIT | +| **Best for** | Production-grade stateful systems | +| **MCP/A2A** | No native support yet | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Most control over execution flow (conditional logic, branching, parallel) +- Best debugging/observability via LangSmith companion tooling +- Production-proven with enterprise deployments +- Model-agnostic: assign different models to different agent nodes +- Mature checkpointing and state persistence + +**Key weaknesses:** +- Steepest learning curve (requires graph theory knowledge) +- No native MCP/A2A support yet +- Higher initial development time vs. CrewAI + +> Source: [DataCamp - Framework Comparison](https://www.datacamp.com/tutorial/crewai-vs-langgraph-vs-autogen), [DEV - Agent Showdown 2026](https://dev.to/topuzas/the-great-ai-agent-showdown-of-2026-openai-autogen-crewai-or-langgraph-1ea8) + +--- + +### 4.2 CrewAI + +| Field | Value | +|-------|-------| +| **URL** | [crewai.com](https://crewai.com/) | +| **Architecture** | Role-based teams (roles, goals, backstories) | +| **Languages** | Python | +| **License** | MIT | +| **Best for** | Quick prototyping, team-based workflows | +| **A2A** | Added A2A support | +| **MCP** | Not natively | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Most beginner-friendly (40% faster time-to-production vs. LangGraph) +- Role-based metaphor mirrors real organizations +- YAML config keeps agent definitions readable +- Active development (unlike AutoGen) +- Added A2A support for interoperability + +**Key weaknesses:** +- Less mature monitoring/observability tooling +- Python-only +- Less granular control than LangGraph for complex workflows + +> Source: [CrewAI](https://crewai.com/), [OpenAgents Blog - Frameworks Compared](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared) + +--- + +### 4.3 AutoGen / Microsoft Agent Framework + +| Field | Value | +|-------|-------| +| **URL** | [github.com/microsoft/agent-framework](https://github.com/microsoft/agent-framework) | +| **Previous** | AutoGen + Semantic Kernel (merged October 2025) | +| **Languages** | Python, .NET | +| **License** | MIT | +| **Status** | Release Candidate (February 2026), GA target end of Q1 2026 | +| **MCP/A2A** | Both supported natively | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What happened:** +- Microsoft merged AutoGen and Semantic Kernel into a unified "Microsoft Agent Framework" in October 2025 +- AutoGen is now in maintenance mode (bug fixes/security only) +- Semantic Kernel features are being absorbed +- GA 1.0 targeted for end of Q1 2026 + +**Key features:** +- Unified programming model: Python and .NET +- Graph-based workflows: sequential, concurrent, handoff, group chat patterns +- Multi-provider: Azure OpenAI, OpenAI, Anthropic, AWS Bedrock, Ollama, etc. +- Native interoperability: A2A, AG-UI, MCP, OpenAPI +- Enterprise: session-based state management, middleware, telemetry + +**Key concern:** Community disruption from the merge. AutoGen users forced to migrate. Strategic shift raises questions about long-term stability of Microsoft's agent strategy. + +> Source: [Visual Studio Magazine - Agent Framework](https://visualstudiomagazine.com/articles/2025/10/01/semantic-kernel-autogen--open-source-microsoft-agent-framework.aspx), [Microsoft Learn - Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/), [Microsoft Azure Blog](https://azure.microsoft.com/en-us/blog/introducing-microsoft-agent-framework/) + +--- + +### 4.4 OpenAI Agents SDK + +| Field | Value | +|-------|-------| +| **URL** | [openai.github.io/openai-agents-python](https://openai.github.io/openai-agents-python/) | +| **GitHub** | [openai/openai-agents-python](https://github.com/openai/openai-agents-python) | +| **Languages** | Python, TypeScript/JavaScript | +| **License** | MIT | +| **Version** | 0.13.0 (March 2026) | +| **Maturity** | Production-ready | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Core primitives:** Agents, Handoffs, Tools (functions + MCP + hosted), Guardrails, Human-in-the-loop, Sessions, Tracing, Realtime Agents (voice). + +**Provider-agnostic:** Supports OpenAI Responses/Chat APIs and 100+ other LLMs despite being OpenAI-branded. + +**Orchestration patterns:** Agents-as-tools (bounded subtask) and handoffs (specialist takes over). + +**MCP support:** Native. Agents can use MCP servers as tool providers. + +> Source: [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/), [Agents SDK Review (mem0)](https://mem0.ai/blog/openai-agents-sdk-review), [OpenAI Developers 2025](https://developers.openai.com/blog/openai-for-developers-2025/) + +--- + +### 4.5 Google Agent Development Kit (ADK) + +| Field | Value | +|-------|-------| +| **URL** | [google.github.io/adk-docs](https://google.github.io/adk-docs/) | +| **GitHub** | [google/adk-python](https://github.com/google/adk-python) (17.8K stars) | +| **Languages** | Python, Go | +| **License** | Apache 2.0 | +| **A2A** | Native integration | +| **MCP** | Native support | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Same framework powering Google's Agentspace and Customer Engagement Suite +- Native A2A + MCP: first-party protocol support +- Rich tool ecosystem: built-in tools, MCP servers, LangChain/LlamaIndex integration, agents as tools +- LiteLLM integration for multi-provider model access (Anthropic, Meta, Mistral, etc.) +- Deploy anywhere: Cloud Run, Vertex AI Agent Engine, GKE +- 3.3M monthly downloads + +**Key weakness:** Optimized for Gemini/Google ecosystem. Model-agnostic in theory, but best experience with Google Cloud. + +> Source: [Google Developers Blog - ADK](https://developers.googleblog.com/en/agent-development-kit-easy-to-build-multi-agent-applications/), [ADK Docs](https://google.github.io/adk-docs/), [ADK + A2A](https://google.github.io/adk-docs/a2a/) + +--- + +### 4.6 AWS Strands Agents + +| Field | Value | +|-------|-------| +| **URL** | [strandsagents.com](https://strandsagents.com/) | +| **GitHub** | [strands-agents](https://github.com/strands-agents) (2,000+ stars) | +| **Languages** | Python, TypeScript | +| **License** | Apache 2.0 | +| **Version** | 1.0 (production-ready) | +| **A2A** | Native support | +| **MCP** | First-class support | +| **Downloads** | 150K+ on PyPI | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Key features:** +- Model-driven approach: model reasons about when to use sub-agents +- Multi-agent patterns: Graph, Swarm, Workflow +- Native A2A: expose agents as A2A servers, communicate with other A2A agents +- First-class MCP: thousands of tools accessible +- Model-agnostic: Bedrock, Anthropic, Gemini, LiteLLM, Ollama, OpenAI, and more +- Deploy: Lambda, Fargate, EKS, Bedrock AgentCore, Docker, Kubernetes +- OpenTelemetry observability built-in + +**Key concern:** Newer entrant (May 2025), smaller community than LangGraph/CrewAI. AWS ecosystem-optimized. + +> Source: [AWS Blog - Strands Agents](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/), [Strands 1.0](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-1-0-production-ready-multi-agent-orchestration-made-simple/), [AWS - A2A on Strands](https://aws.amazon.com/blogs/opensource/open-protocols-for-agent-interoperability-part-4-inter-agent-communication-on-a2a/) + +--- + +### 4.7 OpenAgents + +| Field | Value | +|-------|-------| +| **URL** | [openagents.org](https://openagents.org/) | +| **GitHub** | [openagents-org/openagents](https://github.com/openagents-org/openagents) | +| **Languages** | Python | +| **License** | Open source | +| **A2A** | Native support | +| **MCP** | Native support | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Unique positioning:** Only framework with native first-class support for BOTH MCP and A2A protocols. Purpose-built for interoperable agent networks. + +**Key features:** +- Persistent agent communities (not one-shot pipelines) +- LLM-agnostic (any model provider) +- Agent discovery: agents find each other in workspaces +- @mention delegation between agents +- Manages Claude, Codex, Aider, and more from a single CLI +- Self-hosted agent networks via SDK + +**Key concern:** Smaller community and less production-hardened than LangGraph/CrewAI. Newer project. + +> Source: [OpenAgents Blog - Comparison](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared), [OpenAgents GitHub](https://github.com/openagents-org/openagents) + +--- + +### 4.8 GitAgent + +| Field | Value | +|-------|-------| +| **URL** | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) | +| **Created** | March 2026 (very new) | +| **Type** | Framework-agnostic agent definition format | +| **License** | Open source | +| **Reliability** | 5/10 | +| **Confidence** | 6/10 | + +**What it does:** "Docker for AI Agents" -- a universal format to define an agent once and export it to any framework. + +**Export targets:** `gitagent export -f [framework]` supports OpenAI, Claude Code, LangChain/LangGraph, CrewAI, AutoGen. + +**Key innovation:** +- Agent identity in SOUL.md + skills/ directories +- Git-native state management (Markdown files, not vector DBs) +- Human-in-the-loop via standard PRs (not custom dashboards) +- Enterprise compliance (FINRA, SEC) built-in + +**What ports:** Prompts, persona, constraints, tool schemas, role policies, model preferences. +**What stays:** Runtime orchestration, state machines, live tool execution, memory I/O. + +**Key concern:** Brand new (March 2026). No production track record. Early-stage community. + +> Source: [MarkTechPost - GitAgent](https://www.marktechpost.com/2026/03/22/meet-gitagent-the-docker-for-ai-agents-that-is-finally-solving-the-fragmentation-between-langchain-autogen-and-claude-code/), [GitAgent GitHub](https://github.com/open-gitagent/gitagent) + +--- + +### 4.9 Goose (Block) + +| Field | Value | +|-------|-------| +| **URL** | [block.github.io/goose](https://block.github.io/goose/) | +| **GitHub** | [block/goose](https://github.com/block/goose) (30,000+ stars, 350+ contributors) | +| **Created by** | Block (January 2025) | +| **Governance** | AAIF / Linux Foundation | +| **License** | Apache 2.0 | +| **Type** | Local-first AI agent (CLI + Desktop) | +| **MCP** | Core architecture built on MCP | +| **LLM Support** | 25+ providers (commercial + local models) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it does:** An extensible, local-first AI agent. Goes beyond code suggestions -- runs shell commands, edits files, executes code, orchestrates multi-step workflows. Reference implementation for MCP. + +**Key facts:** +- 110+ releases since January 2025 +- 3,000+ MCP servers available in the ecosystem +- Founding project of AAIF alongside MCP and AGENTS.md +- Works with any LLM (multi-model config for cost optimization) +- Modular via MCP extensions + +> Source: [Block - Introducing Goose](https://block.xyz/inside/block-open-source-introduces-codename-goose), [Goose GitHub](https://github.com/block/goose), [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) + +--- + +### 4.10 Framework Comparison Table + +| Framework | MCP | A2A | Multi-Provider | Languages | Architecture | Maturity | GitHub Stars | +|-----------|-----|-----|----------------|-----------|-------------|----------|-------------| +| **LangGraph** | No | No | Yes | Py, JS/TS | Graph-based | High | ~40K | +| **CrewAI** | No | Yes | Yes | Py | Role-based | Medium-High | ~30K | +| **MS Agent Framework** | Yes | Yes | Yes | Py, .NET | Graph + Conversational | Medium (RC) | ~40K (combined) | +| **OpenAI Agents SDK** | Yes | No | Yes (100+ LLMs) | Py, TS/JS | Handoff-based | High | N/A | +| **Google ADK** | Yes | Yes | Yes (via LiteLLM) | Py, Go | Hierarchical | Medium-High | ~18K | +| **AWS Strands** | Yes | Yes | Yes | Py, TS | Model-driven | Medium | ~2K | +| **OpenAgents** | Yes | Yes | Yes | Py | Network-based | Low | ~1K | +| **Goose** | Yes (core) | No | Yes (25+) | Rust/TS | MCP-based | Medium-High | ~30K | +| **GitAgent** | No | No | Yes (portability) | Universal | Format/spec | Very Low | New | + +--- + +## 5. Desktop/Local Orchestration Tools + +### 5.1 VS Code Multi-Agent Hub + +| Field | Value | +|-------|-------| +| **URL** | [code.visualstudio.com](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development) | +| **Release** | January 2026 (v1.109) | +| **Agents** | GitHub Copilot + Claude + Codex | +| **Subagents** | Parallel execution | +| **MCP** | Full MCP Apps support | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it is:** VS Code as a multi-agent command center. Run Claude, Codex, and Copilot side by side from a single interface. + +**Key features (v1.109+):** +- **Agent Sessions view**: orchestrate multiple AI assistants, delegate tasks, compare outputs +- **Parallel subagents**: fire off multiple independent tasks simultaneously +- **Agent types**: local (interactive), background (CLI/worktrees), cloud (GitHub PRs), third-party +- **Custom agents**: specialized roles (research, implementation, security) with defined tools, instructions, and models +- **MCP Apps**: tool calls return interactive UI components (dashboards, forms, visualizations) +- **Copilot Memory**: context retention across interactions + +**Agent HQ (GitHub):** Announced at GitHub Universe 2025, launched February 2026. Assign issues to Copilot, Claude, Codex, or all three to compare results. + +**Agent strengths differentiation:** +- Copilot: fast autocomplete, repo-specific patterns, inline experience +- Claude: thorough, trade-off analysis, multi-file changes +- Codex: fast generation, algorithmic tasks, concise output + +> Source: [VS Code Blog - Multi-Agent](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development), [The New Stack - VS Code Multi-Agent](https://thenewstack.io/vs-code-becomes-multi-agent-command-center-for-developers/), [GitHub Blog - Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) + +--- + +### 5.2 Augment Code Intent + +| Field | Value | +|-------|-------| +| **URL** | [augmentcode.com](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration) | +| **Platform** | macOS (public beta, February 2026); Windows waitlist | +| **Type** | Standalone desktop app | +| **Architecture** | Living Spec + three-tier agents (Coordinator, Specialists, Verifier) | +| **BYOA** | Yes (Claude Code, Codex, OpenCode) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Unique concept: Living Spec.** A shared document that acts as the canonical source of truth. Reduces prompt drift, stale assumptions, and conflicting parallel work. Coordinator breaks requirements into tasks, specialists execute in isolated git worktrees, verifier checks results against spec. + +**BYOA (Bring Your Own Agent):** Use Claude Code, Codex, or OpenCode inside Intent's workspace. Free tier for BYOA; Context Engine requires subscription. + +**Context Engine:** Processes 400,000+ files through semantic dependency analysis. Agents gain understanding of service boundaries, API contracts, dependency relationships. + +**Benchmark claims:** SWE-bench Pro: Auggie 51.80% vs Claude Code 49.75% vs Cursor 50.21%. + +**Relevance to Claude Agent Teams UI:** Intent is the closest conceptual competitor. Both aim to be a desktop UI for multi-agent coding orchestration. Key differences: +- Intent uses living specs; our app uses kanban boards +- Intent is macOS-only; our app is cross-platform (Electron) +- Intent is commercial (freemium); ours is 100% free/open-source +- Intent requires BYOA agents; ours is Claude Code-native with potential for multi-provider + +> Source: [Augment Code - Intent](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration), [Intent vs Claude Code](https://www.augmentcode.com/tools/intent-vs-claude-code), [Best AI Coding Desktop Apps 2026](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) + +--- + +### 5.3 OpenAI Codex Desktop App + +| Field | Value | +|-------|-------| +| **Created** | February 2, 2026 | +| **Platform** | macOS only (Windows late 2026) | +| **Type** | Standalone desktop app | +| **Architecture** | "Command center for agents" | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**What it does:** Centralizes multiple AI coding agents in a single interface. Manage parallel AI workflows, review automated changes, run long-running background tasks. + +**Key gap vs. our app:** Codex Desktop is OpenAI-only. No multi-provider agent support. No kanban board. No team collaboration features. + +> Source: [IntuitionLabs - Codex App](https://intuitionlabs.ai/articles/openai-codex-app-ai-coding-agents), [Augment Code - Desktop Apps Comparison](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) + +--- + +## 6. Relevance for Claude Agent Teams UI + +### Could any of these serve as a universal backend for a desktop AI team management UI? + +**Highest relevance tools:** + +| Tool | Why Relevant | Integration Path | Effort | +|------|-------------|------------------|--------| +| **MCP** | Our agents already use MCP. Universal tool protocol. | Already integrated via Claude Code | Low | +| **A2A** | Could enable cross-provider agent communication (Claude + Codex + Gemini agents) | Implement A2A client/server in Electron main process | Medium-High | +| **LiteLLM** | Unified routing to any LLM. A2A agent support. Self-hosted. | Spawn local proxy, route all requests through it | Medium | +| **OpenAgents** | Native MCP + A2A. Manages Claude, Codex, Aider from single CLI. | Could replace/augment Claude Code CLI orchestration | High | +| **AGENTS.md** | Would make our kanban tasks/specs consumable by any agent | Generate AGENTS.md from team config | Low | + +### Strategic positioning + +Our app (Claude Agent Teams UI) has unique advantages that no competitor offers: + +1. **Kanban board** -- nobody else has this for agent orchestration +2. **100% free, open-source, local-first** -- vs. Augment Intent (freemium), Codex App (OpenAI-only), VS Code (ecosystem lock-in) +3. **Claude Code-native** -- deepest integration with Claude's agent teams feature +4. **Cross-team communication** -- agents coordinate across teams, not just within + +### Potential evolution path + +``` +Phase 1 (Current): Claude Code-native orchestration + | +Phase 2: Add AGENTS.md export (make teams consumable by other agents) + | +Phase 3: Add A2A server (expose our teams as A2A-discoverable agents) + | +Phase 4: Add multi-provider support via LiteLLM/A2A + (Claude + Codex + Gemini agents on same kanban board) + | +Phase 5: Full "universal AI team management" platform +``` + +**Key risk:** The VS Code multi-agent hub (Agent HQ) has massive distribution advantage. Our differentiation must come from superior UX (kanban), deeper team management, and open-source community. + +### Market context +- Gartner: 40% of enterprise apps will feature AI agents by end of 2026 (up from 5%) +- IDC: agentic AI spending to exceed $1.3T by 2029 (31.9% CAGR) +- UiPath: 65% of organizations piloting agentic systems by mid-2025 + +--- + +## Sources + +### Protocols & Standards +- [Google Developers Blog - A2A Protocol](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [Google Cloud Blog - A2A Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Linux Foundation - A2A Project](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents) +- [A2A GitHub](https://github.com/a2aproject/A2A) +- [MCP Official Site](https://modelcontextprotocol.io/) +- [MCP 2026 Roadmap](http://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/) +- [MCP Specification 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) +- [Pento - A Year of MCP](https://www.pento.ai/blog/a-year-of-mcp-2025-review) +- [The New Stack - MCP Roadmap 2026](https://thenewstack.io/model-context-protocol-roadmap-2026/) +- [MCP Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol) +- [IBM - ACP](https://www.ibm.com/think/topics/agent-communication-protocol) +- [IBM Research - ACP](https://research.ibm.com/blog/agent-communication-protocol-ai) +- [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) +- [AGENTS.md Official Site](https://agents.md/) +- [InfoQ - AGENTS.md](https://www.infoq.com/news/2025/08/agents-md/) +- [IBM - What is BeeAI](https://www.ibm.com/think/topics/beeai) +- [NIST - AI Agent Standards Initiative](https://www.nist.gov/news-events/news/2026/02/announcing-ai-agent-standards-initiative-interoperable-and-secure) + +### Governance +- [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) +- [OpenAI - AAIF](https://openai.com/index/agentic-ai-foundation/) +- [Anthropic - AAIF](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) +- [Block - AAIF](https://block.xyz/inside/block-anthropic-and-openai-launch-the-agentic-ai-foundation) +- [AAIF Official Site](https://aaif.io/) + +### Frameworks & SDKs +- [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) +- [OpenAI Agents SDK GitHub](https://github.com/openai/openai-agents-python) +- [Google ADK Docs](https://google.github.io/adk-docs/) +- [Google ADK GitHub](https://github.com/google/adk-python) +- [AWS Strands Agents](https://strandsagents.com/) +- [AWS - Introducing Strands](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/) +- [AWS - Strands 1.0](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-1-0-production-ready-multi-agent-orchestration-made-simple/) +- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) +- [Microsoft Learn - Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) +- [Visual Studio Magazine - Agent Framework](https://visualstudiomagazine.com/articles/2025/10/01/semantic-kernel-autogen--open-source-microsoft-agent-framework.aspx) +- [LangGraph](https://github.com/langchain-ai/langgraph) +- [CrewAI](https://crewai.com/) +- [OpenAgents](https://openagents.org/) +- [OpenAgents GitHub](https://github.com/openagents-org/openagents) +- [GitAgent GitHub](https://github.com/open-gitagent/gitagent) +- [MarkTechPost - GitAgent](https://www.marktechpost.com/2026/03/22/meet-gitagent-the-docker-for-ai-agents-that-is-finally-solving-the-fragmentation-between-langchain-autogen-and-claude-code/) +- [Goose GitHub](https://github.com/block/goose) +- [Block - Introducing Goose](https://block.xyz/inside/block-open-source-introduces-codename-goose) + +### Routing & Gateways +- [LiteLLM Docs](https://docs.litellm.ai/) +- [LiteLLM GitHub](https://github.com/BerriAI/litellm) +- [OpenRouter](https://openrouter.ai/) +- [Top 5 LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/top-5-litellm-alternatives-in-2026/) + +### Desktop Tools +- [VS Code Blog - Multi-Agent](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development) +- [The New Stack - VS Code Multi-Agent](https://thenewstack.io/vs-code-becomes-multi-agent-command-center-for-developers/) +- [GitHub Blog - Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) +- [Augment Code - Intent](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration) +- [Augment Code - Best Desktop Apps](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) +- [IntuitionLabs - Codex App](https://intuitionlabs.ai/articles/openai-codex-app-ai-coding-agents) + +### Framework Comparisons +- [DataCamp - CrewAI vs LangGraph vs AutoGen](https://www.datacamp.com/tutorial/crewai-vs-langgraph-vs-autogen) +- [OpenAgents Blog - Frameworks Compared](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared) +- [DEV - Agent Showdown 2026](https://dev.to/topuzas/the-great-ai-agent-showdown-of-2026-openai-autogen-crewai-or-langgraph-1ea8) +- [Shakudo - Top 9 AI Agent Frameworks](https://www.shakudo.io/blog/top-9-ai-agent-frameworks) +- [AIMultiple - Top 5 Agentic Frameworks 2026](https://aimultiple.com/agentic-frameworks) + +### Market Research +- [Gravitee - A2A vs MCP](https://www.gravitee.io/blog/googles-agent-to-agent-a2a-and-anthropics-model-context-protocol-mcp) +- [RUH.AI - AI Agent Protocols 2026 Complete Guide](https://www.ruh.ai/blogs/ai-agent-protocols-2026-complete-guide) +- [Thoughtworks - MCP Impact 2025](https://www.thoughtworks.com/en-us/insights/blog/generative-ai/model-context-protocol-mcp-impact-2025) +- [Shipyard - Claude Code Multi-Agent 2026](https://shipyard.build/blog/claude-code-multi-agent/) diff --git a/docs/research/ai-maestro-deep-dive.md b/docs/research/ai-maestro-deep-dive.md new file mode 100644 index 00000000..5267ec2c --- /dev/null +++ b/docs/research/ai-maestro-deep-dive.md @@ -0,0 +1,334 @@ +# AI Maestro — Deep Dive Research + +**Дата исследования:** 2026-03-25 +**Репозиторий:** [23blocks-OS/ai-maestro](https://github.com/23blocks-OS/ai-maestro) +**Сайт:** [ai-maestro.23blocks.com](https://ai-maestro.23blocks.com/) +**Автор:** Juan Pelaez / 23blocks +**Лицензия:** MIT + +--- + +## Общая информация + +AI Maestro — open-source оркестратор AI-агентов с системой навыков (Skills System), дашбордом для управления агентами, собственным протоколом обмена сообщениями (AMP) и поддержкой мультимашинных mesh-сетей. Позиционирует себя как "The Future of Work: Humans + AI Agents". + +### Метрики репозитория (на 25 марта 2026) + +| Метрика | Значение | +|---------|----------| +| Stars | **557** | +| Forks | 77 | +| Open issues | 8 | +| Коммитов | 890+ | +| Контрибьюторов | ~5 (249 от jpelaez-23blocks, далее 9, 7, 4, 2) | +| Создан | 10 октября 2025 | +| Последний коммит | 25 марта 2026 (сегодня!) | +| Последний релиз | v0.26.4 (25 марта 2026) | +| Языки | TypeScript 89%, Shell 6.7%, JS 3.4%, CSS 0.5% | +| Размер репо | ~312 MB | + +**Вывод:** Проект активно развивается, коммиты ежедневные. Но по факту это проект одного человека (Juan Pelaez — 249 из ~270 коммитов от людей). 4 коммита от аккаунта `claude` — что ироничным образом подтверждает AI-происхождение кода. + +--- + +## Origin Story + +Цитата из описания проекта: +> "I had 35 terminals and couldn't tell which was which." + +Автор запускал 35+ AI-агентов одновременно и стал "human mailman" между ними — копировал контекст из одного терминала в другой. Сейчас утверждает, что запускает **80+ агентов** на нескольких компьютерах. + +--- + +## Поддерживаемые агенты + +### Заявленная совместимость: +- **Claude Code** (основной) +- **Aider** +- **Cursor** +- **GitHub Copilot CLI** +- **OpenCode** (через Skills) +- **Любой терминальный AI-агент** + +### Как это работает: +AI Maestro не является "мультипровайдерным" в том смысле, что он сам вызывает API разных LLM. Он работает на уровне **терминалов** — оборачивает tmux-сессии и предоставляет dashboard для управления ими. Любой инструмент, который работает в терминале, может быть "агентом" в AI Maestro. + +**Важное уточнение:** AI Maestro НЕ абстрагирует LLM-провайдеров (как например LiteLLM). Он оркестрирует **процессы в терминале**. Claude Code внутри себя использует Anthropic API, Aider может использовать OpenAI/Anthropic/etc — но AI Maestro этого не контролирует. + +--- + +## Архитектура + +### Tech Stack + +| Компонент | Технология | Роль | +|-----------|-----------|------| +| Frontend | **Next.js** | Web-дашборд | +| Terminal | **xterm.js** | Эмуляция терминала в браузере | +| Database | **CozoDB** | Граф-реляционная БД для памяти и Code Graph | +| Code Analysis | **ts-morph** | Парсинг AST для Code Graph | +| Process Mgmt | **tmux** | Мультиплексор терминалов | +| Networking | **Peer Mesh** | P2P сеть между машинами | + +### CozoDB — выбор базы данных + +CozoDB (3 926 stars) — необычный выбор. Это транзакционная реляционно-графовая-векторная БД, использующая **Datalog** для запросов. Ключевые фичи: +- Реляционная модель + графовые алгоритмы +- Векторный поиск через HNSW-индексы +- Встраиваемая (embedded) +- Time-travel запросы + +Это позволяет хранить и код-граф (структура кодобазы), и память агентов (conversation history), и выполнять векторный поиск — в одной БД. + +### Три уровня "интеллекта" + +1. **Memory** — Персистентная память через CozoDB. Агенты помнят прошлые решения и разговоры. +2. **Code Graph** — Визуализация структуры кодобазы. ts-morph парсит AST, извлекает классы/функции/импорты, строит граф зависимостей. Delta-индексация (переиндексируются только изменённые файлы). +3. **Documentation** — Автогенерируемая документация из кода, доступная агентам для поиска. + +### Мультимашинная mesh-сеть + +- Peer-to-peer топология: каждая машина — равноправный узел +- Нет центрального сервера +- Новая машина автоматически присоединяется к mesh +- Все агенты со всех машин видны в одном дашборде +- Поддержка remote access через Tailscale VPN + +### Структура репозитория + +``` +/app — Application logic +/components — UI-компоненты +/services — Backend-сервисы +/plugin — Система плагинов для Claude Code +/agent-container — Контейнеризированные агенты +/infrastructure/terraform/aws-agent — AWS deployment +/docs — Документация +``` + +--- + +## Agent Messaging Protocol (AMP) + +AMP — это **собственный протокол** 23blocks для межагентной коммуникации. Отдельный репозиторий: [agentmessaging/protocol](https://github.com/agentmessaging/protocol) (20 stars, Apache 2.0). + +### Ключевые характеристики + +| Параметр | Значение | +|----------|----------| +| Версия | 0.1.3-draft | +| Лицензия | Apache 2.0 | +| Безопасность | Ed25519 криптографические подписи | +| Адресация | Email-подобная: `agent-name@tenant.provider` | +| Спецификации | 11 документов | + +### Формат сообщений + +Конверт содержит: +- `from` / `to` — адреса отправителя/получателя +- `subject` — тема +- `priority` — приоритет +- `in_reply_to` — для тредов +- `payload` — произвольный JSON +- `signature` — Ed25519 подпись + +Каноническая подпись: `from|to|subject|priority|in_reply_to|SHA256(payload)` + +### Доставка сообщений + +4 способа: +1. **WebSocket** — реалтайм для подключённых агентов +2. **REST API** — polling +3. **Webhook** — HTTP POST push +4. **Relay queue** — очередь для офлайн-агентов (TTL 7 дней по умолчанию) +5. **Mesh** — локальная маршрутизация без интернета + +### Провайдеры (федеративная модель) + +- **AI Maestro** (self-hosted): `http://localhost:23000/api/v1` — работает +- **crabmail.ai** — "coming soon" +- **lolainbox.com** — "coming soon" + +### Безопасность + +- Ed25519 подписи предотвращают подмену отправителя +- Trust-level аннотации для внешних сообщений +- Key revocation с федеративным распространением +- Защита от prompt injection (34 паттерна) +- SSRF-превенция для webhook + +### Критическая оценка AMP + +**Плюсы:** +- Формально специфицированный протокол (11 документов) +- Криптографическая безопасность по умолчанию +- Федеративная модель +- Поддержка офлайн-агентов + +**Минусы:** +- Всего 20 stars на GitHub +- Единственная реализация — сам AI Maestro +- Федерация заявлена, но 2 из 3 провайдеров "coming soon" +- По факту проприетарный протокол одного проекта, несмотря на Apache 2.0 лицензию +- Не совместим с ACP (Agent Communication Protocol), MCP или другими стандартами + +--- + +## Kanban Board + +AI Maestro включает kanban-доску с: +- **5 колонок** (статусы задач) +- **Drag-and-drop** перемещение карточек +- **Зависимости** между задачами +- **Шаренные задачи** между агентами +- Часть "War Room" — split-pane интерфейс для командных встреч + +**Детали реализации Kanban ограничены** — в документации и на сайте нет скриншотов или подробного описания UX. Описание сводится к маркетинговым фразам: "full Kanban board with drag-and-drop, dependencies, and 5 status columns." + +--- + +## Gateways — внешние интеграции + +AI Maestro поддерживает "Gateways" для подключения к внешним сервисам: +- **Slack** +- **Discord** +- **Email** +- **WhatsApp** + +Маршрутизация через синтаксис `@AIM:agent-name`. Заявлена защита от 34 паттернов prompt injection. + +--- + +## Skills System + +Система плагинов, устанавливаемых через `npx skills add`. Навыки автоматически триггерят: +- Поиск по памяти +- Запросы к Code Graph +- Поиск документации + +Совместим с 30+ агентами через "Agent Skills Standard". + +### Agent Identity (AID) + +Новая фича (v0.26.0, 24 марта 2026): агенты могут аутентифицироваться на OAuth 2.0 серверах используя Ed25519 identity. Без паролей, без API-ключей. + +--- + +## Релизная активность + +Последние 5 релизов (за 2 дня!): + +| Версия | Дата | Описание | +|--------|------|----------| +| v0.26.4 | 25.03.2026 | AMP mesh routing fix | +| v0.26.3 | 24.03.2026 | AID v0.2.0: независим от AMP | +| v0.26.2 | 24.03.2026 | Dynamic discovery для verification | +| v0.26.1 | 24.03.2026 | Переименование installer, auto-discover skills | +| v0.26.0 | 24.03.2026 | Agent Identity (AID) интеграция | + +5 релизов за 2 дня — это очень высокий темп. Может свидетельствовать как об активной разработке, так и о незрелости (частые фиксы только что выпущенных фич). + +--- + +## Сравнение с нашим продуктом (Claude Agent Teams UI) + +### Фундаментальные различия + +| Аспект | AI Maestro | Claude Agent Teams UI | +|--------|-----------|----------------------| +| **Подход** | Терминальный оркестратор (tmux wrapper) | Нативная UI-надстройка над Claude Code Agent Teams | +| **Агенты** | Любой терминальный AI | Claude Code (нативный Agent Teams API) | +| **Мультипровайдер** | Да (на уровне терминалов) | Нет (Claude-only, но с multi-model: Opus/Sonnet/Haiku) | +| **Kanban** | Есть (5 колонок, drag-drop, dependencies) | Есть (5 колонок, drag-drop, real-time) | +| **Межагентная связь** | AMP protocol (собственный) | Нативный Claude Code inbox/task system | +| **Code Review** | Не указан | Diff view с approve/reject/comment | +| **Deep Analytics** | Memory + Code Graph + Docs | Session analysis, context tracking, token usage | +| **Мультимашинность** | Peer mesh network | Нет (локальный) | +| **UI** | Web (Next.js, браузер) | Desktop (Electron) | +| **Процесс** | tmux sessions | stream-json CLI processes | + +### Где AI Maestro сильнее + +1. **Мультимашинность** — peer mesh сеть, агенты на разных компьютерах. У нас этого нет вообще. +2. **Мультиагентность** — поддерживает Claude, Aider, Cursor, Copilot и любой терминальный инструмент. Мы только Claude Code. +3. **Memory System** — CozoDB с графовыми запросами, векторным поиском, персистентной памятью. У нас аналитика сессий, но не полноценная "память" агентов. +4. **Code Graph** — визуализация кодобазы через ts-morph + CozoDB. У нас такого нет. +5. **External Gateways** — Slack, Discord, Email, WhatsApp. У нас встроенный MCP-сервер, но не gateway к мессенджерам. +6. **Scale** — заявляет 80+ агентов. Наш продукт ориентирован на команды 3-8 агентов. + +### Где наш продукт сильнее + +1. **Нативная интеграция с Claude Code** — мы работаем с официальным Agent Teams API, а не просто оборачиваем терминалы. Наши агенты нативно общаются через inbox, шарят задачи, имеют structured task references. +2. **Code Review** — полноценный diff view с accept/reject/comment, как в Cursor. У AI Maestro это не заявлено. +3. **Kanban UX** — у нас real-time обновления, direct messaging на карточках, quick actions, structured task references с кросс-ссылками. AI Maestro заявляет Kanban, но без деталей UX. +4. **Deep Session Analysis** — bash commands, reasoning, subprocesses breakdown, chunk timeline. AI Maestro показывает терминал, но не анализирует сессии. +5. **Context Monitoring** — 6 категорий контекста (CLAUDE.md, tool outputs, thinking, team coordination), token usage by category. Уникальная фича. +6. **Desktop App** — нативный Electron, не браузерная вкладка. +7. **DM to agents** — прямые сообщения конкретному агенту с карточки задачи. +8. **Zero-setup** — встроенная установка Claude Code и аутентификация. AI Maestro требует Node.js + tmux + установку. +9. **Built-in Code Editor** — редактор файлов с Git support. +10. **Post-compact context recovery** — восстановление инструкций после context compaction. + +### Фундаментальная разница в философии + +**AI Maestro** = "Terminal multiplexer on steroids" — оборачивает tmux, добавляет UI и межагентную коммуникацию. Агенты — это просто терминальные сессии. Протокол AMP — собственный, не стандартный. + +**Claude Agent Teams UI** = "CTO dashboard for Claude teams" — нативная надстройка над Claude Code Agent Teams, с глубоким пониманием внутренних протоколов Claude (stream-json, inbox, tasks). Агенты — это структурированные сущности с ролями, задачами и коммуникацией. + +--- + +## Рыночное позиционирование + +AI Maestro позиционирован в [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) в категории **"Parallel Agent Runners"** наряду с 38 другими инструментами. + +### Конкуренты AI Maestro (не наши) + +| Инструмент | Фокус | Stars | +|-----------|-------|-------| +| **Maestro** (RunMaestro) | Desktop orchestrator, Claude/Codex/OpenCode | 2000+ | +| **Vibe Kanban** (BloopAI) | Kanban + Git worktree + MCP | N/A | +| **Claw-Kanban** | Kanban + role-based auto-assignment | N/A | +| **Agent Orchestrator** (ComposioHQ) | Plugin-based, tracker-agnostic | N/A | + +RunMaestro (отдельный проект) — самый серьёзный конкурент для AI Maestro: 2000+ stars, desktop app, Group Chat, Auto Run, Mobile Remote Control. + +--- + +## Оценки + +### Надёжность решения: 6/10 + +- Проект одного разработчика (249 из ~270 коммитов) +- Зависимость от CozoDB (нишевая БД) +- 5 релизов за 2 дня — признак незрелости +- AMP протокол — 20 stars, единственная реализация +- Нет community reviews (Reddit/HN) +- Нет automated tests (не видно в описании) + +### Уровень угрозы для нашего продукта: 4/10 + +- Другая ниша: мультиагентный терминальный оркестратор vs нативный Claude Teams UI +- Наша аудитория — пользователи Claude Code Agent Teams +- Их аудитория — пользователи 3+ разных AI-инструментов +- Пересечение небольшое: только если пользователь Claude Code решит добавить другие инструменты + +### Что стоит позаимствовать + +1. **Memory System** — персистентная память агентов между сессиями. Наши агенты теряют контекст при рестарте. CozoDB — overengineered для нас, но концепция ценная. +2. **Code Graph** — визуализация кодобазы. Можно реализовать через tree-sitter или ts-morph + простое хранение. +3. **Multi-machine** — даже не P2P mesh, но хотя бы возможность подключаться к remote Claude Code сессиям. +4. **External integrations** — Slack/Discord уведомления о прогрессе задач. + +--- + +## Источники + +- [GitHub: 23blocks-OS/ai-maestro](https://github.com/23blocks-OS/ai-maestro) +- [AI Maestro Website](https://ai-maestro.23blocks.com/) +- [AMP Protocol: agentmessaging/protocol](https://github.com/agentmessaging/protocol) +- [Agent Messaging Protocol Website](https://agentmessaging.org/) +- [CozoDB](https://github.com/cozodb/cozo) +- [Medium: "Your AI Agent Has Amnesia"](https://medium.com/23blocks/your-ai-agent-has-amnesia-heres-how-we-fixed-it-49980712f2e4) (paywall) +- [Medium: "From 47 Terminal Windows to One Dashboard"](https://medium.com/23blocks/building-ai-maestro-from-47-terminal-windows-to-one-beautiful-dashboard-64cd25ff3b43) (paywall) +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [Maestro vs Superpowers vs ECC comparison gist](https://gist.github.com/jeffscottward/de77a769d9e25a8ccdc92b65291b1c34) diff --git a/docs/research/ai-orchestration-tools-part2.md b/docs/research/ai-orchestration-tools-part2.md new file mode 100644 index 00000000..374387d2 --- /dev/null +++ b/docs/research/ai-orchestration-tools-part2.md @@ -0,0 +1,705 @@ +# AI Agent Orchestrators & Dispatchers — Part 2 + +> Research date: 2026-03-24 +> Focus: Provider-agnostic agent abstraction layers, dispatch systems, and multi-agent coding orchestrators +> Scope: NEW tools not covered in Part 1 + +--- + +## Tier 1: Desktop Apps & ADEs (Agentic Development Environments) + +These are the most relevant to our product — desktop applications that provide a UI layer for managing multiple coding agents. + +### 1. Emdash (YC W26) + +- **GitHub:** https://github.com/generalaction/emdash +- **Stars:** ~2,700+ +- **License:** Open source (exact license TBD) +- **Language:** Electron-based desktop app +- **Unique:** First YC-backed "Agentic Development Environment" (ADE). Run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH. + +**Agent providers:** 22 CLI agents supported — Claude Code, Qwen Code, Amp, Codex, Gemini CLI, and more. + +**Architecture:** +- Each agent runs in its own git worktree with full isolation +- Built-in ticket integrations: Linear, GitHub, Jira — pass tickets directly to agents +- Remote development via SSH/SFTP with secure keychain credential storage +- Built-in diff review, PR creation, CI/CD checks, and merge +- Privacy-first: Emdash itself sends no code/chat data to any servers + +**Integration potential:** DIRECT COMPETITOR. Very similar concept to our app. Key differences: Emdash is more a "parallel agent launcher" while we focus on team orchestration with inter-agent communication and kanban management. + +**Maturity:** Active development, YC-backed, growing fast (966 -> 2700 stars in weeks). Available for macOS (Apple Silicon + Intel) and Linux. + +**Source:** [GitHub](https://github.com/generalaction/emdash) | [emdash.sh](https://www.emdash.sh/) | [YC profile](https://www.ycombinator.com/companies/emdash) + +--- + +### 2. Constellagent + +- **GitHub:** https://github.com/owengretzinger/constellagent +- **Stars:** TBD (listed in awesome-agent-orchestrators) +- **License:** Open source +- **Language:** macOS desktop app +- **Unique:** Each agent gets its own terminal, editor, and git worktree — all in one window. macOS-native UI. + +**Agent providers:** Any CLI-based coding agent (Claude Code, Codex, Gemini CLI, etc.) + +**Architecture:** +- Side-by-side agent sessions with isolated git worktrees +- Built-in terminal + code editor per agent +- macOS-native (not Electron) + +**Integration potential:** Simpler than our app but validates the "multi-agent desktop UI" market. macOS-only limits audience. + +**Source:** [GitHub](https://github.com/owengretzinger/constellagent) + +--- + +## Tier 2: CLI Orchestrators with Provider Abstraction + +### 3. ORCH + +- **GitHub:** https://www.orch.one/ (listed in awesome-agent-orchestrators) +- **Stars:** TBD +- **License:** MIT +- **Language:** TypeScript +- **Unique:** CLI runtime with formal STATE MACHINE for task lifecycle (`todo -> in_progress -> review -> done`). Agents talk to each other, share context, and run 24/7 as a daemon. + +**Agent providers:** 5 built-in adapters — Claude (Anthropic), OpenCode (multi-provider via OpenRouter), Codex (OpenAI), Cursor, and a universal Shell adapter (anything that takes a prompt). + +**Architecture:** +- Each AI tool wrapped in adapter implementing common interface (`src/infrastructure/adapters/`) +- Event bus with wildcard subscriptions for TUI activity feed +- Git worktree isolation per agent +- Inter-agent messaging + shared context +- All state stored locally in `.orchestry/` — no telemetry +- "Set goal at 10pm, wake up to pull requests" + +**Integration potential:** Very interesting adapter pattern. The common interface + event bus architecture is close to what we'd need for a provider abstraction layer. Could study their adapter implementations. + +**Source:** [orch.one](https://www.orch.one/) | [DEV article](https://dev.to/oxgeneral/orchestrating-a-team-of-ai-agents-from-a-single-cli-4h6) + +--- + +### 4. Agent Swarm (Desplega AI) + +- **GitHub:** https://github.com/desplega-ai/agent-swarm +- **Stars:** Notable stargazers (Andrew Ng, Chip Huyen). Exact count TBD. +- **License:** MIT +- **Language:** TypeScript +- **Unique:** Full lead/worker coordination with Docker isolation, compounding memory, persistent agent identity (SOUL.md, IDENTITY.md), and DAG-based workflow engine. + +**Agent providers:** Claude Code (primary), pi-mono. Provider adapter pattern via `HARNESS_PROVIDER=claude|pi`. Codex, Gemini CLI support planned. + +**Architecture:** +- Lead agent decomposes tasks, delegates to worker agents in Docker containers +- MCP API server backed by SQLite for communication and state +- Persistent searchable filesystem shared across swarm (agent-fs) +- Compounding memory: agents learn from every session via summaries + OpenAI embeddings +- Persistent identity: agents have evolving SOUL.md/IDENTITY.md files +- DAG-based workflow engine with triggers, conditions, checkpoint durability +- Integrations: Slack, GitHub, GitLab, Email, Linear +- Dashboard UI with real-time monitoring + debug dashboard with SQL query interface + +**Integration potential:** Most feature-rich orchestrator found. The persistent identity and compounding memory concepts are innovative. Dashboard UI could inspire features. + +**Source:** [GitHub](https://github.com/desplega-ai/agent-swarm) | [Docs](https://docs.agent-swarm.dev) | [Dashboard](https://agent-swarm.desplega.sh/) + +--- + +### 5. Kodo + +- **GitHub:** Listed in awesome-agent-orchestrators +- **Stars:** ~37 +- **License:** Open source +- **Unique:** SWE-bench verified. Autonomous multi-agent orchestrator with independent architect and tester verification stages in work cycles. + +**Agent providers:** Claude Code, Codex, Gemini CLI + +**Architecture:** +- Directs agents through work cycles +- Independent architect verification +- Independent tester verification +- SWE-bench validated results + +**Integration potential:** Small project but interesting verification-centric workflow approach. + +**Source:** [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) + +--- + +### 6. AgentFactory (Supaku) + +- **GitHub:** https://github.com/supaku/agentfactory +- **Stars:** TBD +- **License:** Open source +- **Language:** TypeScript +- **Unique:** "Software factory" with assembly-line pipeline (dev -> QA -> acceptance). Distributed worker pool via Redis. Exposes fleet as MCP server. Implements A2A protocol v0.3.0. + +**Agent providers:** Claude, Codex, Spring AI (via `AgentProvider` interface) + +**Architecture:** +- `AgentProvider` interface for pluggable agent backends +- Pipeline: development -> QA -> acceptance (like CI/CD for agents) +- Distributed worker pool: webhook server + Redis queue + multiple worker nodes +- MCP server exposure: any MCP-aware client can interact with fleet +- A2A protocol support (v0.3.0) — operates as both client and server +- Spring AI Bench integration for benchmarking +- Scaffolding: `@supaku/create-agentfactory-app` +- One-click deploy to Vercel/Railway +- Linear integration for issue tracking + +**Integration potential:** The A2A + MCP server approach is very forward-looking. Enterprise Java teams can use Spring AI agents alongside Claude/Codex. + +**Source:** [GitHub](https://github.com/supaku/agentfactory) + +--- + +## Tier 3: Framework-Level Abstraction Layers + +### 7. Mozilla any-agent + +- **GitHub:** https://github.com/mozilla-ai/any-agent +- **Stars:** ~1,100+ +- **License:** Open source (Mozilla) +- **Language:** Python +- **Unique:** META-FRAMEWORK. Build agent once, switch frameworks by changing `AgentFramework` config parameter. Normalized logging via open-inference. Trace-first evaluation with LLM-as-judge. + +**Agent frameworks supported:** Abstraction over multiple agent frameworks (not providers) — lets you swap between different frameworks without rewriting agent code. + +**Architecture:** +- Single interface to different agent frameworks +- Normalized logging regardless of framework +- Trace-first evaluation approach +- Multi-agent via "Agents-As-Tools" pattern +- Companion projects: `any-llm` (LLM provider abstraction), `any-guardrail`, `Agent Factory` (natural language to agents), `mcpd` ("requirements.txt for agentic systems") + +**Integration potential:** Different abstraction level than what we need. Useful if we want to abstract over agent frameworks rather than coding agent CLIs. The `mcpd` tool for MCP server management is interesting. + +**Source:** [GitHub](https://github.com/mozilla-ai/any-agent) | [Blog](https://blog.mozilla.ai/introducing-any-agent-an-abstraction-layer-between-your-code-and-the-many-agentic-frameworks/) | [Docs](https://mozilla-ai.github.io/any-agent/) + +--- + +### 8. VoltAgent + +- **GitHub:** https://github.com/VoltAgent/voltagent +- **Stars:** TBD (active GitHub org with multiple repos) +- **License:** MIT +- **Language:** TypeScript +- **Unique:** "Refine.dev for AI agents" — TypeScript-first with n8n-style visual debugging console. Multi-agent orchestration with resumable streaming and voice support. + +**Agent providers:** OpenAI, Anthropic, Google, and others — swap by changing config, not code. + +**Architecture:** +- LLM-agnostic: provider swap via config +- Memory adapters (durable, cross-run) +- Resumable streaming: clients reconnect to in-flight streams after refresh +- RAG + Knowledge Base: managed document ingestion, chunking, embeddings, search +- Guardrails: runtime input/output validation +- Evals: built-in eval suites +- Voice: TTS/STT with OpenAI, ElevenLabs, custom providers +- VoltOps Console: observability, automation, deployment, evals (cloud & self-hosted) +- MCP docs server for AI coding assistants + +**Integration potential:** Great TypeScript framework if we want to build our own agent abstraction. The resumable streaming pattern is relevant for Electron apps. + +**Source:** [GitHub](https://github.com/VoltAgent/voltagent) | [voltagent.dev](https://voltagent.dev/) + +--- + +### 9. Mastra + +- **GitHub:** https://github.com/mastra-ai/mastra +- **Stars:** 7,500+ (as of early reports, likely higher now) +- **License:** Open source (EE features source-available under enterprise license) +- **Language:** TypeScript +- **Created by:** Team behind Gatsby (YC-backed) +- **Unique:** "Batteries-included TypeScript AI framework." Used by Replit Agent 3 (improved task success 80% -> 96%). Supports 81 LLM providers and 2,436+ models via Vercel AI SDK. + +**Agent providers:** 40+ providers via Vercel AI SDK (OpenAI, Anthropic, Gemini, etc.) + +**Architecture:** +- Model routing: 40+ providers through one interface +- Human-in-the-loop: suspend/resume with stored execution state +- Context management: conversation history, data retrieval, working + semantic memory +- MCP servers: expose agents/tools/resources via MCP +- Integration with React, Next.js, Node.js +- Serverless deployment: Vercel, Cloudflare, Netlify, or Mastra hosting +- `npm create mastra@latest` for quick start + +**Integration potential:** Very mature TypeScript SDK. Could be used as an underlying agent framework in our Electron app. The human-in-the-loop suspend/resume is exactly what we need for kanban workflows. + +**Source:** [GitHub](https://github.com/mastra-ai/mastra) | [mastra.ai](https://mastra.ai/) | [YC profile](https://www.ycombinator.com/companies/mastra) + +--- + +## Tier 4: Coding Agent Platforms (Individual Agents with Multi-Provider Support) + +### 10. Goose (Block) + +- **GitHub:** https://github.com/block/goose +- **Stars:** 27,000+ +- **License:** Apache 2.0 +- **Language:** Rust +- **Unique:** By Block (Square, Cash App). 25+ LLM providers, 3,000+ MCP servers. Contributed to Linux Foundation's Agentic AI Foundation alongside Anthropic's MCP and OpenAI's AGENTS.md. + +**Agent providers:** 25+ LLM providers (OpenAI, Anthropic, Google, DeepSeek, local via Ollama). Can even use Claude Code as a model provider inside Goose. + +**Architecture:** +- Multi-provider with multi-model configuration (use different models for different tasks in same session) +- Subagents for parallel task execution with isolated workspaces +- MCP-native (among first agents to support MCP) +- CLI + Desktop app (not IDE-locked) +- Recipes system for reusable workflows +- Completely free + open source; you only pay LLM API costs + +**Integration potential:** Goose itself is a coding agent, not an orchestrator. But its multi-provider architecture and MCP integration patterns are worth studying. Could be one of the agents our UI orchestrates. + +**Source:** [GitHub](https://github.com/block/goose) | [block.github.io/goose](https://block.github.io/goose/) | [AI Tool Analysis Review](https://aitoolanalysis.com/goose-ai-review/) + +--- + +### 11. OpenCode + +- **GitHub:** https://github.com/opencode-ai/opencode +- **Stars:** 95K-120K+ (massive growth, surpassed Claude Code in stars) +- **License:** Open source +- **Language:** Go (Bubble Tea TUI) +- **Created by:** Team behind SST (Serverless Stack) and terminal.shop +- **Unique:** Go-based terminal agent with 75+ LLM providers. Built-in TUI with Vim-like editor. 5M+ monthly developers. + +**Agent providers:** 75+ providers — OpenAI, Anthropic, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, OpenRouter, and more. + +**Architecture:** +- Interactive TUI built with Bubble Tea +- Session management with persistent SQLite storage +- Multiple agent types: plan agent (analysis), general-purpose agent (full tool access) +- Parallel work units +- MCP integration for external tools +- LSP integration for code intelligence +- Provider-agnostic philosophy: "as models evolve, being provider-agnostic is important" + +**Integration potential:** OpenCode is a single-agent tool, not an orchestrator. However, it's the most popular open-source alternative to Claude Code. Worth considering as a supported runtime for our orchestrator. + +**Source:** [GitHub](https://github.com/opencode-ai/opencode) | [opencode.ai](https://opencode.ai/) | [OpenCode Docs - Agents](https://opencode.ai/docs/agents/) | [OpenCode Docs - Providers](https://opencode.ai/docs/providers/) + +--- + +### 12. OpenHands (formerly OpenDevin) + +- **GitHub:** https://github.com/OpenHands/OpenHands +- **Stars:** 68,600+ +- **License:** MIT +- **Language:** Python +- **Unique:** Cloud coding agent platform with $18.8M Series A. Solves 87% of bug tickets same day. Event stream architecture with typed events. + +**Agent providers:** 100+ providers via LiteLLM (OpenAI, Anthropic, Google, etc.). Git providers: GitHub, GitLab, Bitbucket, Azure DevOps, Forgejo. + +**Architecture:** +- Event stream architecture: all agent-environment interactions as typed events through central hub +- Agent -> Runtime -> EventStream -> LLM pipeline +- Hierarchical agent coordination via delegation tool +- Sub-agents as independent conversations inheriting parent config +- Distributed deployment: WebSocket for agent/runtime communication +- Isolated Docker/Kubernetes environments +- V1 SDK transition: moving from mandatory Docker to optional sandboxing +- Software Agent SDK for building custom agents + +**Integration potential:** Enterprise-grade platform. The event stream architecture and typed events pattern could inspire our agent communication protocol. + +**Source:** [GitHub](https://github.com/OpenHands/OpenHands) | [openhands.dev](https://openhands.dev/) | [Software Agent SDK paper](https://arxiv.org/html/2511.03690v1) + +--- + +## Tier 5: Specialized Multi-Agent Coding Systems + +### 13. Liza (Disciplined Multi Coding Agent System) + +- **GitHub:** https://github.com/liza-mas/liza +- **Stars:** TBD +- **License:** Open source +- **Unique:** "Lisa Simpson vs Ralph Wiggum" philosophy. 55+ LLM failure modes mapped to countermeasures. Behavioral contracts, blackboard coordination, and explicit state machine. MOST disciplined approach to multi-agent coding. + +**Architecture:** +- Behavioral contract with Tier 0 invariants (never violated) +- Blackboard coordination: shared file tracks goals, tasks, assignments, history +- Stateless agents with external specs for context handoff +- Approval Request mechanism forces reasoning before acting +- Deterministic pre/post hooks at role transitions +- Orchestrator-routed model selection +- Agent roles: Coder, Security Auditor, Security Audit Reviewer +- Sprint-based workflow: autonomous within sprints, human reviews between sprints +- CLI: `liza setup`, `liza init`, `liza agent coder`, `liza validate`, `liza watch`, `liza sprint-checkpoint` + +**Integration potential:** The behavioral contract and blackboard coordination concepts are academically interesting and could improve agent reliability. + +**Source:** [GitHub](https://github.com/liza-mas/liza) + +--- + +### 14. Multi-Agent Coding System (Danau5tin) + +- **GitHub:** https://github.com/Danau5tin/multi-agent-coding-system +- **Stars:** TBD +- **License:** Open source +- **Unique:** Reached #13 on Stanford's TerminalBench (slightly above Claude Code). Novel "Context Store" for multi-agent knowledge sharing. RL-trained 14B Orca-Agent model. + +**Architecture:** +- Orchestrator + Explorer + Coder agents with knowledge artifacts +- Context Store: persistent knowledge layer with selective injection +- Trust Calibration Strategy: adaptive delegation based on task complexity +- Orchestrator cannot read/modify code directly — operates at architectural level only +- Companion project: Orca-Agent-RL (14B model, trained on 32x H100s) + +**Integration potential:** The Context Store pattern for multi-agent knowledge sharing is a novel approach worth studying. + +**Source:** [GitHub](https://github.com/Danau5tin/multi-agent-coding-system) | [Hacker News](https://news.ycombinator.com/item?id=45113348) + +--- + +### 15. Open SWE (LangChain) + +- **GitHub:** https://github.com/langchain-ai/open-swe +- **Stars:** 7,700+ +- **License:** MIT +- **Language:** Python +- **Unique:** Built on LangGraph Deep Agents framework. Multi-agent architecture (Manager, Planner, Programmer, Reviewer). Captures patterns used by Stripe, Ramp, Coinbase for internal coding agents. + +**Agent providers:** Any LLM via LangGraph. Multiple sandbox providers: Modal, Daytona, Runloop, LangSmith. + +**Architecture:** +- Manager -> Planner -> Programmer -> Reviewer pipeline +- Isolated Daytona sandboxes per task +- Subagent orchestration via Deep Agents task tool +- Middleware hooks: deterministic middleware around agent loop +- AGENTS.md support: read from sandbox, injected into system prompt +- Async & cloud-native: multiple tasks in parallel, "double texting" support +- Integrations: Linear, Slack, GitHub + +**Integration potential:** Enterprise-grade coding agent framework. The middleware hook pattern and AGENTS.md support are interesting patterns. + +**Source:** [GitHub](https://github.com/langchain-ai/open-swe) | [LangChain Blog](https://blog.langchain.com/introducing-open-swe-an-open-source-asynchronous-coding-agent/) + +--- + +### 16. DeerFlow 2.0 (ByteDance) + +- **GitHub:** https://github.com/bytedance/deer-flow +- **Stars:** 37,000+ +- **License:** MIT +- **Language:** Python +- **Unique:** ByteDance's "SuperAgent harness." Ground-up rewrite of v1. Multi-service architecture with Nginx reverse proxy. Skills system for extensibility. #1 GitHub Trending within 24h of launch. + +**Agent providers:** Model-agnostic — any OpenAI-compatible API (GPT-4, Claude, Gemini, DeepSeek, local models via Ollama). + +**Architecture:** +- Harness (core): agent orchestration, tools, sandbox, models, MCP, skills, config +- App layer: FastAPI Gateway API + IM channel integrations (Feishu, Slack, Telegram) +- Lead agent decomposes tasks, spawns sub-agents with scoped contexts +- Docker-sandboxed execution per sub-agent (own filesystem, bash terminal) +- Skills system: Markdown-based workflow definitions with best practices +- Persistent JSON memory system (user context, history, facts with confidence scores) +- Three sandbox modes (configurable via config.yaml) +- MCP servers with OAuth token flows + +**Integration potential:** Impressive scale and ByteDance backing. Skills system is interesting — Markdown-based workflow definitions could be adapted for our agent team recipes. + +**Source:** [GitHub](https://github.com/bytedance/deer-flow) | [deerflow.tech](https://deerflow.tech/) | [DeepWiki analysis](https://deepwiki.com/bytedance/deer-flow) + +--- + +## Tier 6: Infrastructure & Runtime Frameworks + +### 17. Dapr Agents (CNCF) + +- **GitHub:** https://github.com/dapr/dapr-agents +- **Stars:** Part of Dapr ecosystem (34K+ stars for main Dapr project) +- **License:** Open source (CNCF) +- **Language:** Python (only) +- **Unique:** v1.0 GA announced at KubeCon Europe 2026. DurableAgent class: every LLM call and tool execution is a checkpoint. Kill process mid-workflow, resume from last saved point. + +**Agent providers:** LLM provider decoupling via Dapr Conversation API — swap LLMs without code changes (OpenAI, Anthropic, AWS Bedrock, etc.) + +**Architecture:** +- Kubernetes-native: distribute thousands of agents across pods/nodes +- DurableAgent with checkpoint/resume +- Multi-agent via Dapr pub/sub messaging +- Coordination models: LLM-based, random, round-robin +- SPIFFE identity for agent-to-agent authorization +- Distributed tracing via OTEL + Prometheus metrics +- mTLS encrypted communication +- Enterprise adoption: ZEISS, EU logistics companies + +**Integration potential:** Overkill for desktop app, but the DurableAgent checkpoint/resume pattern could inspire our agent crash recovery. Python-only is a limitation. + +**Source:** [GitHub](https://github.com/dapr/dapr-agents) | [Diagrid Blog](https://www.diagrid.io/blog/dapr-agents-1-0-durable-cloud-native-production-ready) | [KubeCon announcement](https://jangwook.net/en/blog/en/dapr-agents-v1-cncf-production-ai-framework/) + +--- + +### 18. Sandcastle + +- **GitHub:** https://github.com/gizmax/Sandcastle +- **Stars:** TBD +- **License:** Open source +- **Language:** Python +- **Unique:** EU AI Act compliance built-in. 63 integrations. YAML-defined workflows. Smart model routing (quality/cost/latency constraints per step). 118 built-in + 118 community workflow templates. + +**Agent providers:** OpenAI, Anthropic, plus many more via multi-provider routing. Budget pressure detection forces cheaper models. + +**Architecture:** +- YAML workflow definitions with DAG dependencies and parallel branches +- 4 sandbox backends: E2B cloud microVMs, Docker, Cloudflare Workers edge, local subprocess +- Smart model routing with historical performance data +- 5 browser automation modes (Playwright, Computer Use, DOM Extract, LightPanda, Browserbase) +- Real-time SSE dashboard (runs, costs, schedules, approvals, experiments) +- A/B testing models and prompts per step with auto-deployment +- Replay & checkpoints: re-run from any step +- PII redaction and tamper-evident audit trail +- Agent runtime with circuit breaker and pool management + +**Integration potential:** Enterprise-grade workflow orchestrator. The smart model routing and A/B testing capabilities could be interesting for our team management feature. + +**Source:** [GitHub](https://github.com/gizmax/Sandcastle) | [gizmax.cz/sandcastle](https://gizmax.cz/sandcastle/) + +--- + +### 19. AgentScope + Runtime (Alibaba/Tongyi Lab) + +- **GitHub:** https://github.com/agentscope-ai/agentscope (~18,900+ stars) + https://github.com/agentscope-ai/agentscope-runtime +- **License:** Open source +- **Language:** Python (+ Java implementation) +- **Unique:** Production-ready agent platform with SEPARATE runtime framework. Framework-agnostic runtime (not tied to AgentScope itself). "Agent as API" approach. Java SDK available. + +**Agent providers:** OpenAI, DashScope, Gemini, Anthropic, self-hosted open-source models. Provider-agnostic via formatter system. + +**Architecture:** +- AgentScope: agent development framework with multi-agent collaboration +- AgentScope Runtime: separate deployment infrastructure (sandboxing, state management, memory) +- Runtime is framework-agnostic — works with other agent frameworks too +- Agent-as-API: white-box development experience +- Multi-layer hook system for observability (OpenTelemetry integration) +- Serverless deployment support (Alibaba Cloud FC) +- Java implementation (Spring AI Alibaba, Langchain4j) +- ReAct agent built implementation-agnostic + +**Integration potential:** The separation of agent framework from runtime is architecturally clean. The framework-agnostic runtime concept aligns with our need for a provider-neutral orchestration layer. + +**Source:** [GitHub (main)](https://github.com/agentscope-ai/agentscope) | [GitHub (runtime)](https://github.com/agentscope-ai/agentscope-runtime) + +--- + +### 20. OpenAgentsControl (OAC) + +- **GitHub:** https://github.com/darrenhinde/OpenAgentsControl +- **Stars:** ~2,900 +- **License:** Open source +- **Language:** Built on OpenCode +- **Unique:** Plan-first, approval-based execution. "Minimal Viable Information" (MVI) principle = 80% token reduction. Editable agents via Markdown files. + +**Agent providers:** Model-agnostic — Claude, GPT, Gemini, local models (Ollama, LM Studio). Built on OpenCode. + +**Architecture:** +- Propose -> Approve -> Execute model +- MVI principle: load only relevant patterns per task (80% token savings) +- Editable agents: modify behavior by editing Markdown files +- Custom Agent System Builder wizard +- Coding patterns committed to repos (team consistency) +- Multi-language: TypeScript, Python, Go, Rust + +**Integration potential:** The MVI token reduction technique and editable Markdown agents are useful ideas. Plan-first approach aligns with structured team workflows. + +**Source:** [GitHub](https://github.com/darrenhinde/OpenAgentsControl) | [BrightCoding review](https://www.blog.brightcoding.dev/2026/02/19/openagentscontrol-the-revolutionary-ai-agent-framework) + +--- + +### 21. NeuroLink (Juspay) + +- **GitHub:** https://github.com/juspay/neurolink +- **Stars:** ~119 +- **License:** MIT +- **Language:** TypeScript +- **Unique:** Enterprise-grade unified API for 12 major AI providers and 100+ models. Extracted from production systems at Juspay. Multi-provider failover and automatic cost optimization. + +**Agent providers:** 12 providers unified: OpenAI, Google, Anthropic, AWS, Azure, Groq, Together AI, Mistral, Cohere, Fireworks, Cloudflare, Ollama. 300+ models via OpenRouter integration. + +**Architecture:** +- Single API for 12+ providers (switch with one parameter change) +- 64+ built-in tools and MCP servers +- Multi-step agentic loops with per-step tool execution control +- Persistent memory (Redis/S3/SQLite) +- HITL workflows +- Structured output with Zod schemas +- Auto cost optimization and multi-provider failover +- LiteLLM integration for 100+ models +- TypeScript SDK + professional CLI + +**Integration potential:** Good TypeScript SDK for unified LLM access. If we need to add direct LLM provider abstraction (beyond just spawning CLI agents), NeuroLink's approach is solid. + +**Source:** [GitHub](https://github.com/juspay/neurolink) + +--- + +### 22. Pi-mono (badlogic) + +- **GitHub:** https://github.com/badlogic/pi-mono +- **Stars:** TBD +- **License:** Open source +- **Language:** TypeScript (npm packages) +- **Unique:** Minimal terminal coding harness with 4 modes: interactive, print/JSON, RPC, and SDK for embedding. Extensible via TypeScript Extensions, Skills, Prompt Templates, and Themes. + +**Agent providers:** Multi-provider via `Api` type union. Providers added by extending the API identifier system. + +**Architecture:** +- Monorepo with multiple packages (`packages/coding-agent`, etc.) +- 4 modes: interactive, print/JSON, RPC (process integration), SDK (embedding) +- OpenClaw SDK integration for real-world use +- Extension system: TypeScript Extensions, Skills, Prompt Templates, Themes +- Packaged as npm packages for sharing +- Used as a provider in Agent Swarm (`HARNESS_PROVIDER=pi`) + +**Integration potential:** The RPC and SDK modes are interesting for embedding a coding agent into our Electron app. Minimal footprint philosophy is appealing. + +**Source:** [GitHub](https://github.com/badlogic/pi-mono) + +--- + +### 23. Agentic Fleet (Qredence) + +- **GitHub:** https://github.com/Qredence/agentic-fleet +- **Stars:** TBD +- **License:** Open source +- **Language:** Python (backend) + React 19 + TypeScript (frontend) +- **Unique:** Built on Microsoft Agent Framework's Magentic Fleet pattern. Five-phase pipeline: analysis -> routing -> execution -> progress -> quality. + +**Architecture:** +- Backend: Python 3.12/3.13, FastAPI, Typer CLI, DSPy, Microsoft Agent Framework +- Frontend: React 19, TypeScript, Vite, Tailwind CSS, Radix UI, Shadcn UI +- ToolRegistry adapters (Tavily search, browser automation, code execution, MCP) +- Real-time SSE/WebSocket streaming +- Five-phase task pipeline + +**Integration potential:** Good example of combining Microsoft Agent Framework with a React frontend. The ToolRegistry adapter pattern is relevant. + +**Source:** [GitHub](https://github.com/Qredence/agentic-fleet) + +--- + +### 24. Plandex + +- **GitHub:** https://github.com/plandex-ai/plandex +- **Stars:** 15,086 +- **License:** MIT +- **Language:** Go +- **Unique:** Terminal-based AI coding with 2M token context, full version control for AI plans (branches, diff review), and cumulative diff review sandbox. + +**Agent providers:** Combine models from Anthropic, OpenAI, Google, and open source providers. + +**Architecture:** +- 2M token context handling (~100k per file) +- Tree-sitter project maps for 20M+ token directories +- Version control for plans (branches, compare models) +- Cumulative diff review sandbox (changes separate until approved) +- Full autonomy capable but highly configurable step-by-step review +- Git integration with auto-commit + +**Integration potential:** Single agent, not an orchestrator. But the plan version control and diff sandbox concepts are relevant to our code review feature. + +**Source:** [GitHub](https://github.com/plandex-ai/plandex) | [plandex.ai](https://plandex.ai/) + +--- + +## Tier 7: Evolving / Archived (Notable Mentions) + +### 25. ControlFlow -> Marvin 3.0 (PrefectHQ) + +- **GitHub:** https://github.com/PrefectHQ/ControlFlow (archived) -> https://github.com/PrefectHQ/marvin +- **Unique:** Task-centric architecture with Prefect 3.0 observability. Evolved into Marvin 3.0 using Pydantic AI for LLM interactions (full range of providers). +- **Note:** ControlFlow is archived, Marvin 3.0 is the successor with broader provider support. + +**Source:** [GitHub (ControlFlow)](https://github.com/PrefectHQ/ControlFlow) | [GitHub (Marvin)](https://github.com/PrefectHQ/marvin) + +--- + +## Summary Comparison Table + +| Tool | Type | Stars | Language | Agent Providers | Desktop App | Key Differentiator | +|------|------|-------|----------|----------------|-------------|-------------------| +| **Emdash** | ADE | 2,700+ | Electron | 22 CLI agents | Yes | YC W26, tickets integration | +| **Constellagent** | ADE | TBD | macOS native | Any CLI agent | Yes (macOS only) | Terminal+editor+worktree per agent | +| **ORCH** | CLI | TBD | TypeScript | 5 adapters | TUI | State machine, inter-agent messaging | +| **Agent Swarm** | CLI+Dashboard | TBD | TypeScript | Claude, Pi | Dashboard UI | Compounding memory, persistent identity | +| **AgentFactory** | CLI+Web | TBD | TypeScript | Claude, Codex, Spring AI | Dashboard | A2A protocol, MCP server, Redis pool | +| **Goose** | Agent | 27K+ | Rust | 25+ LLM providers | Desktop+CLI | Linux Foundation, MCP-native | +| **OpenCode** | Agent | 95K+ | Go | 75+ providers | TUI | Fastest-growing, Bubble Tea UI | +| **OpenHands** | Platform | 68K+ | Python | 100+ via LiteLLM | Web UI | $18.8M Series A, event stream arch | +| **DeerFlow** | Harness | 37K+ | Python | Any OpenAI-compatible | Web UI | ByteDance, skills system | +| **Open SWE** | Framework | 7,700+ | Python | Any via LangGraph | No | LangChain, enterprise patterns | +| **Mastra** | Framework | 7,500+ | TypeScript | 40+ providers | No | By Gatsby team, used by Replit | +| **Mozilla any-agent** | Meta-framework | 1,100+ | Python | Framework abstraction | No | Switch frameworks, not providers | +| **VoltAgent** | Framework | TBD | TypeScript | OpenAI, Anthropic, Google | Console UI | Resumable streaming, voice | +| **Dapr Agents** | Runtime | Part of 34K+ | Python | Via Conversation API | No | CNCF, Kubernetes-native, durable agents | +| **Liza** | System | TBD | CLI | Any LLM | No | Behavioral contracts, 55+ failure modes | +| **Sandcastle** | Orchestrator | TBD | Python | Multi-provider routing | Dashboard | EU AI Act, YAML workflows, 118 templates | + +--- + +## Key Architectural Patterns Observed + +### 1. Agent Runtime Interface Pattern +**Used by:** ORCH, Overstory, Agent Swarm, AgentFactory +- Define a common interface (spawn, configure, detect readiness, parse transcript) +- Each agent provider gets an adapter implementing this interface +- Swap providers without changing orchestration logic + +### 2. Git Worktree Isolation Pattern +**Used by:** Emdash, Constellagent, ORCH, Agent Swarm, ComposioHQ +- Standard approach for multi-agent parallel work +- Each agent gets its own worktree + branch +- Merge back via PR/conflict resolution + +### 3. Event Stream / Pub-Sub Architecture +**Used by:** OpenHands, ORCH, Dapr Agents +- All agent interactions as typed events through central hub +- Enables observability, replay, and debugging + +### 4. Checkpoint/Resume (Durable Execution) +**Used by:** Dapr Agents, Sandcastle, Mastra +- Every step saves a checkpoint +- Kill process mid-workflow -> resume from last saved point +- Critical for production reliability + +### 5. Lead-Worker Decomposition +**Used by:** Agent Swarm, DeerFlow, Open SWE, Claude Agent Teams (ours) +- Lead agent decomposes tasks +- Workers execute in isolation +- Results stitched back together + +--- + +## Integration Relevance for Claude Agent Teams UI + +### Direct Competitors (UI level) +1. **Emdash** — Most direct competitor. YC-backed. 22 agents. But lacks kanban, inter-agent communication, and team orchestration. +2. **Constellagent** — macOS-only. Simpler scope. + +### Architectural Inspiration +1. **ORCH** — Adapter interface pattern for agent providers + state machine for task lifecycle +2. **Agent Swarm** — Compounding memory + persistent identity + dashboard UI +3. **AgentFactory** — A2A protocol + MCP server exposure + pipeline stages +4. **VoltAgent** — TypeScript-first framework with resumable streaming (relevant for Electron) +5. **Mastra** — Human-in-the-loop suspend/resume via stored state + +### Worth Studying +1. **Liza** — Behavioral contracts for agent reliability +2. **Mozilla any-agent** — Meta-framework approach +3. **OpenHands** — Event stream architecture at scale +4. **DeerFlow** — Skills system (Markdown-based workflow definitions) + +### Key Competitive Advantages We Have +- **Kanban board** — NO ONE else has this for agent orchestration +- **Inter-agent communication** — Most tools only have lead-worker, not peer-to-peer +- **Code review workflow** — Diff view per task with approve/reject +- **Claude Code Agent Teams native support** — Built specifically for Claude's team protocol +- **Context monitoring** — Token usage tracking by category (unique) +- **Zero-setup onboarding** — Built-in Claude Code installation diff --git a/docs/research/ai-orchestration-tools-part3.md b/docs/research/ai-orchestration-tools-part3.md new file mode 100644 index 00000000..3fc536c4 --- /dev/null +++ b/docs/research/ai-orchestration-tools-part3.md @@ -0,0 +1,861 @@ +# AI Orchestration Tools Research — Part 3 + +**Date:** 2026-03-24 +**Focus:** Emerging/niche agent orchestrators, infrastructure-level tools, protocol-first frameworks, TypeScript/Node-based solutions, fleet managers + +--- + +## Table of Contents + +1. [TypeScript-First Agent Frameworks](#1-typescript-first-agent-frameworks) +2. [Infrastructure & Gateway Layer](#2-infrastructure--gateway-layer) +3. [Durable Execution & Workflow Engines](#3-durable-execution--workflow-engines) +4. [Visual & Low-Code Agent Builders](#4-visual--low-code-agent-builders) +5. [Protocol Standards & Ecosystem](#5-protocol-standards--ecosystem) +6. [Coding Agent Fleet Managers](#6-coding-agent-fleet-managers) +7. [Python-First Frameworks (with TS relevance)](#7-python-first-frameworks-with-ts-relevance) +8. [Summary Matrix](#8-summary-matrix) +9. [Recommendations for Claude Agent Teams UI](#9-recommendations-for-claude-agent-teams-ui) + +--- + +## 1. TypeScript-First Agent Frameworks + +### 1.1 Mastra AI + +- **URL:** https://github.com/mastra-ai/mastra +- **Stars:** ~22.3k (March 2026) +- **npm downloads:** 300k+/week +- **License:** Apache 2.0 +- **Funding:** $13M seed (YC W25, Paul Graham, Gradient Ventures) +- **Source:** [Mastra GitHub](https://github.com/mastra-ai/mastra), [Mastra Docs](https://mastra.ai/docs), [The New Stack](https://thenewstack.io/mastra-empowers-web-devs-to-build-ai-agents-in-typescript/) + +**What it is:** From the team behind Gatsby — a full-featured TypeScript framework for AI agents, workflows, RAG, and memory. Model routing to 40+ providers through one interface (OpenAI, Anthropic, Gemini, etc.). + +**Architecture highlights:** +- **Agents** — autonomous entities with LLM + tools + system instructions +- **Workflows** — graph-based state machines with discrete steps, inputs/outputs +- **Memory** — short-term and long-term memory across threads and sessions +- **Mastra Studio** — local developer playground for visualization/debugging +- **Production tools** — built-in evals, observability, tracing + +**Enterprise adoption:** Replit (Agent 3), SoftBank, Marsh McLennan (75k employees), PayPal, Adobe, Docker. + +**Relevance for Electron integration:** +- Pure TypeScript, runs on Node.js natively +- Can deploy as standalone server or embed in existing Node apps +- Most mature TS agent framework by adoption metrics +- Workflow engine could serve as orchestration backend +- **Confidence: 9/10, Reliability: 9/10** + +--- + +### 1.2 Inngest AgentKit + +- **URL:** https://github.com/inngest/agent-kit +- **Stars:** ~793 +- **npm:** `@inngest/agent-kit` +- **License:** Apache 2.0 (core), proprietary cloud +- **Source:** [AgentKit Docs](https://agentkit.inngest.com/overview), [Inngest Blog](https://www.inngest.com/blog/ai-orchestration-with-agentkit-step-ai) + +**What it is:** TypeScript library for building multi-agent networks with deterministic routing, MCP tooling, and durable execution through Inngest's workflow engine. + +**Architecture highlights:** +- **Agents** — LLM calls with prompts, tools, and MCP +- **Networks** — agents collaborate with shared State and handoff +- **Routers** — from code-based to LLM-based (ReAct) orchestration +- **State** — typed state machine combined with conversation history +- **Tracing** — built-in debug/optimize locally and in cloud +- **React hooks** — `@inngest/use-agent` for frontend integration +- Supports OpenAI, Anthropic, Gemini, and OpenAI-compatible models + +**Key differentiator:** Backed by Inngest's durable execution engine — agents survive crashes, can pause/resume, and handle long-running tasks with automatic retries. This is critical for production reliability. + +**Relevance for Electron integration:** +- Pure TypeScript, lightweight +- Good abstraction for multi-agent networks with routing +- Durable execution is exactly what production agent teams need +- React hooks for UI integration +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 1.3 VoltAgent + +- **URL:** https://github.com/VoltAgent/voltagent +- **Stars:** ~5.1k (March 2026) +- **License:** MIT +- **Source:** [VoltAgent site](https://voltagent.dev/), [GitHub](https://github.com/VoltAgent/voltagent), [MarkTechPost](https://www.marktechpost.com/2025/04/22/meet-voltagent-a-typescript-ai-framework-for-building-and-orchestrating-scalable-ai-agents/) + +**What it is:** Observability-first TypeScript AI agent framework with Memory, RAG, Guardrails, Tools, MCP, Voice, Workflow support. + +**Architecture highlights:** +- **VoltOps Console** — like n8n but for debugging AI agents (cloud & self-hosted) +- Multi-agent workflows via Chain API — compose, branch, orchestrate +- Workflow steps typed with Zod schemas (compile-time safety + runtime validation) +- Human-in-the-loop with pause/resume +- MCP support, bring-your-own LLMs + +**Key differentiator:** Observability as a first-class concern. The VoltOps console provides real-time monitoring, debugging, and workflow visualization — useful for our kanban-style task monitoring. + +**Relevance for Electron integration:** +- MIT license, TypeScript-first, Node.js native +- Observability features could complement our session analysis +- Zod-based typing aligns with our codebase patterns +- **Confidence: 7/10, Reliability: 6/10** + +--- + +### 1.4 HazelJS + +- **URL:** https://github.com/hazel-js/hazeljs +- **Stars:** Small (early alpha) +- **npm:** `@hazeljs/core`, `@hazeljs/agent`, `@hazeljs/ai`, etc. (38+ packages) +- **License:** Apache 2.0 +- **Source:** [HazelJS site](https://hazeljs.ai/), [DEV.to](https://dev.to/arslan_mecom/from-beta-to-alpha-the-hazeljs-journey-in-38-packages-3nad) + +**What it is:** AI-native backend framework with production-grade Agent Runtime, Agentic RAG, and persistent memory. NestJS-style decorator-based API. + +**Architecture highlights:** +- Modular: 40+ installable npm packages (core, ai, agent, rag, memory, flow, auth, cache...) +- **AgentGraph** + **SupervisorAgent** for multi-agent orchestration +- **@hazeljs/flow** — durable workflow engine with wait/resume, idempotency, retries +- **@hazeljs/memory** — pluggable user memory (in-memory, Postgres, Redis, Prisma, vector) +- Decorator-based: `@Agent`, `@Tool`, `@Controller`, `@SemanticSearch` +- Supports OpenAI, Anthropic, Ollama + +**Key differentiator:** Full backend framework approach (not just agents), NestJS-inspired architecture. Combines web framework + agent runtime + durable workflows in one stack. + +**Relevance for Electron integration:** +- TypeScript-first, modular npm packages +- Durable flow engine could be useful +- Very early (alpha) — risky for production +- **Confidence: 5/10, Reliability: 4/10** + +--- + +### 1.5 Agentica + +- **URL:** https://github.com/wrtnlabs/agentica +- **npm:** `@agentica/core`, `@agentica/rpc` +- **License:** MIT +- **Source:** [Agentica Docs](https://wrtnlabs.io/agentica/), [GitHub](https://github.com/wrtnlabs/agentica) + +**What it is:** TypeScript framework specialized in LLM Function Calling, enhanced by the TypeScript compiler. By Wrtn Technologies. + +**Architecture highlights:** +- **Compiler-driven development** — constructs function calling schemas automatically from TypeScript types via `typia` +- Auto-converts Swagger/OpenAPI/MCP documents into function calling schemas +- **Validation feedback** — detects and corrects AI mistakes in argument composition +- **Selector agent** — filters candidate functions to minimize context/tokens +- Supports embedded controllers: Google Calendar, GitHub, Reddit, Slack, etc. + +**Key differentiator:** Instead of complex agent graphs/workflows, you just list TypeScript class types or OpenAPI docs, and Agentica handles function calling automatically. The compiler does the heavy lifting. + +**Relevance for Electron integration:** +- MIT license, TypeScript-native +- Interesting approach for auto-generating tool interfaces +- Could be useful for generating agent tool schemas from existing code +- **Confidence: 6/10, Reliability: 5/10** + +--- + +### 1.6 Strands Agents (AWS) + +- **URL:** https://github.com/strands-agents +- **Downloads:** 14M+ total (since May 2025) +- **License:** Open source (Apache 2.0) +- **Source:** [Strands site](https://strandsagents.com/), [AWS Blog](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/) + +**What it is:** Open source SDK from AWS for building AI agents in Python and TypeScript. Model-driven approach — works with Bedrock, Anthropic, OpenAI, and more. + +**Architecture highlights:** +- TypeScript SDK (preview, December 2025) with full type safety, async/await +- Native tools for AWS service interactions +- Edge device support (sub-100ms latency, ARM/x86, offline with llama.cpp) +- **Steering** — modular prompt mechanism to guide agents mid-execution +- **Evaluations** — validate agent behavior +- Multi-agent patterns: Agent-as-Tool, Swarm + +**Key differentiator:** AWS backing, production-tested at enterprise scale. TypeScript support enables browser/server/Lambda deployment. Edge device support is unique. + +**Relevance for Electron integration:** +- TypeScript SDK available +- AWS-heavy ecosystem may add unwanted dependencies +- Good multi-agent patterns (Agent-as-Tool, Swarm) +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 1.7 OpenAI Agents SDK (TypeScript) + +- **URL:** https://github.com/openai/openai-agents-js +- **Stars:** ~2.1k +- **npm downloads:** ~128k/week +- **License:** MIT +- **Source:** [OpenAI Agents SDK TS](https://openai.github.io/openai-agents-js/) + +**What it is:** Official OpenAI framework for multi-agent workflows and voice agents in TypeScript. + +**Architecture highlights:** +- Agents as tools / Handoffs for cross-agent delegation +- Guardrails for input validation, run in parallel with agent execution +- Function tools with Zod-powered validation and automatic schema generation +- Built-in MCP server tool integration +- TypeScript-first: orchestrate agents using native language features + +**Key differentiator:** Official OpenAI support, lightweight but powerful. Handoff mechanism is well-designed for multi-agent coordination. + +**Relevance for Electron integration:** +- MIT license, pure TypeScript +- Strong typing with Zod +- Model-locked to OpenAI (primary limitation) +- **Confidence: 8/10, Reliability: 7/10** + +--- + +### 1.8 Google ADK for TypeScript + +- **URL:** https://developers.googleblog.com/introducing-agent-development-kit-for-typescript-build-ai-agents-with-the-power-of-a-code-first-approach/ +- **Stars:** ~581 (December 2025 launch) +- **npm downloads:** ~5k/week +- **License:** Apache 2.0 +- **Source:** [Google Developers Blog](https://developers.googleblog.com/introducing-agent-development-kit-for-typescript-build-ai-agents-with-the-power-of-a-code-first-approach/) + +**What it is:** Google's open-source TypeScript framework for building AI agents and multi-agent systems. Code-first approach. + +**Architecture highlights:** +- First-class MCP and A2A protocol support +- Multi-agent coordination +- Code-first TypeScript development + +**Key differentiator:** Google backing, first-class A2A support. Strong protocol-first approach. + +**Relevance for Electron integration:** +- Pure TypeScript, Apache 2.0 +- Still young (December 2025 launch) +- A2A support could be important for future interop +- **Confidence: 6/10, Reliability: 5/10** + +--- + +## 2. Infrastructure & Gateway Layer + +### 2.1 AgentGateway + +- **URL:** https://github.com/agentgateway/agentgateway +- **Stars:** ~2k+ (hit 1M image pulls, 115 contributors) +- **License:** Open source (Linux Foundation) +- **Language:** Rust +- **Source:** [AgentGateway site](https://agentgateway.dev/), [GitHub](https://github.com/agentgateway/agentgateway), [Solo.io Blog](https://www.solo.io/blog/updated-a2a-and-mcp-gateway) + +**What it is:** Next-generation agentic proxy for AI agents and MCP servers. A production-ready gateway for the agentic era, written in Rust. + +**Architecture highlights:** +- **MCP + A2A protocol support** — deep protocol awareness +- **RBAC** — robust role-based access control for MCP/A2A +- **Multi-tenancy** — each tenant with own resources and users +- **Dynamic config via xDS** — no downtime updates +- **Kubernetes-native** — built-in Kubernetes controller via Gateway API +- **LLM routing** — can route traffic to OpenAI, Anthropic, Gemini, Bedrock +- **Legacy API translation** — transforms OpenAPI specs into MCP tools automatically +- **v1.0 released** — production-ready milestone + +**Key differentiator:** The infrastructure layer between agents and their tools/peers. Not an agent framework itself, but the network fabric that makes multi-agent systems work in production. Backed by Solo.io (Envoy/Istio experts), donated to Linux Foundation. + +**Relevance for Electron integration:** +- Written in Rust — not directly embeddable in Node.js +- Could be used as a sidecar/proxy process alongside Electron +- OpenAPI-to-MCP translation is very useful for tool integration +- **Confidence: 6/10, Reliability: 8/10** + +--- + +### 2.2 MCP Gateway & Registry + +- **URL:** https://github.com/agentic-community/mcp-gateway-registry +- **License:** Open source +- **Source:** [GitHub](https://github.com/agentic-community/mcp-gateway-registry) + +**What it is:** Enterprise-ready MCP Gateway & Registry that centralizes AI development tools with OAuth authentication, dynamic tool discovery, and unified access for AI agents and coding assistants. + +**Architecture highlights:** +- Unified MCP Server Gateway — single access point +- MCP Servers Registry — dynamic tool discovery +- Agent Registry & A2A Communication Hub +- Dual authentication: human user + machine-to-machine agent auth +- Keycloak/Entra integration for enterprise SSO + +**Key differentiator:** Governance layer for MCP servers — transforms "scattered MCP server chaos into governed, auditable tool access." This is the missing middleware between agents and tools. + +**Relevance for Electron integration:** +- Could solve MCP server management for team agents +- OAuth/auth layer would be useful for enterprise deployments +- **Confidence: 5/10, Reliability: 5/10** + +--- + +### 2.3 Invariant Gateway + +- **URL:** https://github.com/invariantlabs-ai/invariant-gateway +- **License:** Open source +- **Source:** [GitHub](https://github.com/invariantlabs-ai/invariant-gateway) + +**What it is:** LLM proxy to observe and debug what AI agents are doing. Supports MCP (stdio, SSE, Streamable HTTP) tool calling. Integrates with LiteLLM. + +**Key differentiator:** Focused on observability and debugging of agent tool calls — complementary to our session analysis features. + +--- + +## 3. Durable Execution & Workflow Engines + +### 3.1 Temporal + +- **URL:** https://github.com/temporalio/temporal +- **Stars:** 13k+ +- **Valuation:** $5B (Series D, February 2026, led by a16z) +- **License:** MIT +- **Source:** [Temporal Blog](https://temporal.io/blog/of-course-you-can-build-dynamic-ai-agents-with-temporal), [Temporal A16Z Funding](https://temporal.io/blog/temporal-raises-usd300m-series-d-at-a-usd5b-valuation) + +**What it is:** The foundational durable execution platform. Separates Workflows (orchestration) from Activities (actual work like LLM calls). Agents survive crashes and resume exactly where they left off. + +**Architecture highlights:** +- **Workflow/Activity separation** — deterministic orchestration + non-deterministic LLM calls +- **Event History** — full record of past decisions for crash recovery +- **OpenAI Agents SDK integration** (public preview) — durable agents out of the box +- **PydanticAI integration** — durable Python agents +- **Handles 150k+ actions/second** — battle-tested at scale + +**Enterprise adoption:** OpenAI (Codex runs on Temporal), Replit, Lovable, ADP, Abridge, Washington Post, Block. + +**Key differentiator:** The gold standard for durable execution. If AI agents need to run for hours/days, survive crashes, and handle human-in-the-loop — Temporal is the infrastructure layer that makes it work. + +**Relevance for Electron integration:** +- TypeScript SDK available +- Requires a server component (can self-host or use cloud) +- Adds significant operational complexity +- Best for server-side orchestration, not embedded in Electron +- **Confidence: 9/10, Reliability: 10/10** + +--- + +### 3.2 Trigger.dev + +- **URL:** https://github.com/triggerdotdev/trigger.dev +- **Stars:** ~13.9k +- **License:** Apache 2.0 +- **Source:** [Trigger.dev site](https://trigger.dev/), [AI Agents docs](https://trigger.dev/product/ai-agents), [GitHub](https://github.com/triggerdotdev/trigger.dev) + +**What it is:** Platform for building and deploying fully-managed AI agents and workflows. Durable execution with checkpoint-resume (CRIU). + +**Architecture highlights:** +- **Orchestrator pattern** — breaks jobs into smaller tasks, assigns to specialists +- **Realtime streaming** — live status updates, LLM response streaming to frontend +- **Vercel AI SDK integration** — `ai.tool` creates tools from tasks +- **MCP Server** — interact with projects from Claude Code, Cursor, etc. +- **batch.triggerByTaskAndWait** — efficient parallel coordination +- **Elastic infrastructure** — auto-scaling, concurrency control + +**Key differentiator:** Durable execution + realtime streaming + MCP server. The MCP server integration means agents in our app could trigger/monitor Trigger.dev tasks. + +**Relevance for Electron integration:** +- TypeScript-native +- Server-side platform (not embeddable in Electron directly) +- Good as external orchestration backend +- MCP integration is a natural bridge +- **Confidence: 7/10, Reliability: 8/10** + +--- + +### 3.3 Hatchet + +- **URL:** https://github.com/hatchet-dev/hatchet +- **Stars:** ~4.5k+ +- **License:** MIT +- **SDKs:** Python, TypeScript, Golang +- **Source:** [Hatchet site](https://hatchet.run/), [Docs](https://docs.hatchet.run/v1), [GitHub](https://github.com/hatchet-dev/hatchet) + +**What it is:** Open-source platform for AI agent orchestration, background tasks, and mission-critical workflows. YC W24. + +**Architecture highlights:** +- General-purpose: queue + DAG orchestrator + durable execution engine +- **AI agent primitives** — retries, parallel tool calls, state management, guardrails +- **Fairness** — distributes requests fairly, prevents busy-user overwhelm +- **Concurrency control** — FIFO, LIFO, Round Robin, Priority Queues +- **Human-in-the-loop** — eventing for signaling and streaming +- Built on PostgreSQL — simple self-hosting +- Web UI for monitoring + +**Key differentiator:** Lower operational overhead than Temporal (just PostgreSQL), while providing similar durable execution guarantees. The fairness and concurrency controls are specifically designed for AI agent workloads. + +**Relevance for Electron integration:** +- TypeScript SDK available +- Simpler to self-host than Temporal +- Could be bundled with Electron app (just needs PostgreSQL) +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 3.4 Windmill + +- **URL:** https://github.com/windmill-labs/windmill +- **Stars:** ~13k+ +- **License:** AGPLv3 +- **Source:** [Windmill site](https://www.windmill.dev/), [AI Agents Blog](https://www.windmill.dev/blog/ai-agents) + +**What it is:** Open-source developer platform for building internal tools, workflows, and automations. Supports 20+ languages including TypeScript (Bun runtime). + +**Architecture highlights:** +- **AI Agent Steps** — any Windmill script becomes a tool the AI agent can invoke +- **Automatic tool definitions** — JSON schema from scripts becomes agent tool definitions +- **Multi-language tools** — Python, TypeScript, Go, Rust, PHP, Bash, SQL, etc. +- **MCP integration** — agents connect to external MCP servers +- **Visual DAG editor** + workflows-as-code (Python/TypeScript) +- **~50ms added latency** — very performant + +**Key differentiator:** Any script in any language automatically becomes an agent tool. The "scripts as tools" approach is uniquely pragmatic — no separate tool registration needed. + +**Relevance for Electron integration:** +- AGPLv3 license (restrictive for embedding) +- Docker-based deployment +- Better as external orchestration service +- **Confidence: 6/10, Reliability: 7/10** + +--- + +## 4. Visual & Low-Code Agent Builders + +### 4.1 Dify + +- **URL:** https://github.com/langgenius/dify +- **Stars:** ~129.8k (most-starred agent framework on GitHub) +- **License:** Apache 2.0 (core) +- **Source:** [Dify site](https://dify.ai/), [GitHub](https://github.com/langgenius/dify), [Medium](https://medium.com/@gptproto.official/dify-the-open-source-standard-for-ai-orchestration-777a7bae3bb4) + +**What it is:** Open-source LLM app development platform with visual workflow builder, RAG pipeline, agent capabilities, and model management. + +**Architecture highlights:** +- **Visual canvas** for building AI workflows +- **Hundreds of LLM integrations** — any OpenAI-compatible model +- **50+ built-in tools** for agents +- **MCP integration** — supports HTTP-based MCP services (protocol 2025-03-26) +- Can turn Dify workflows/agents into MCP servers +- **Backend-as-a-Service** — all features via REST API +- 180k+ developers, 59k+ end users + +**Key differentiator:** The most popular open-source agent platform by stars. Strong visual workflow editor. Can expose workflows as MCP servers — meaning our app could consume Dify workflows as tools. + +**Relevance for Electron integration:** +- Python/Docker backend — not embeddable in Electron +- REST API could be consumed from our Electron app +- MCP server mode is very interesting for integration +- **Confidence: 7/10, Reliability: 8/10** + +--- + +### 4.2 n8n + +- **URL:** https://github.com/n8n-io/n8n +- **Stars:** ~180.7k +- **License:** Fair-code (Sustainable Use License) +- **Source:** [n8n site](https://n8n.io/), [AI Agents](https://n8n.io/ai-agents/), [GitHub](https://github.com/n8n-io/n8n) + +**What it is:** Fair-code workflow automation platform with native AI capabilities. 400+ integrations, visual builder + code. + +**Architecture highlights:** +- **AI Agent node** — connects to LLMs, integrates with tools +- **MCP Server** — call n8n workflows from other AI systems +- **Human-in-the-loop** — approval at any workflow point +- **Multi-agent & RAG support** +- Full observability: inspect prompts, responses, execution flow + +**Limitations:** Lacks persistent memory, autonomous planning, and dynamic decision-making. Better for structured tasks than truly autonomous agents. + +**Relevance for Electron integration:** +- TypeScript-based (Node.js) +- Could theoretically be embedded, but it's a full platform +- Fair-code license may be restrictive +- Better as external orchestration service consumed via MCP +- **Confidence: 6/10, Reliability: 7/10** + +--- + +### 4.3 Rivet + +- **URL:** https://github.com/Ironclad/rivet +- **Stars:** ~3.9k +- **License:** Open source +- **Source:** [Rivet site](https://rivet.ironcladapp.com/), [GitHub](https://github.com/Ironclad/rivet) + +**What it is:** Visual AI programming environment for building AI agents with LLMs. By Ironclad. Desktop app + TypeScript runtime library. + +**Architecture highlights:** +- **Node-based visual editor** — drag-and-drop AI chains +- **Real-time debugging** — watch graph execute step-by-step, remote debugging +- **Graph nesting** — modular, reusable components +- **Graphs as YAML** — version control, code review +- **TypeScript runtime library** (`rivet-core`) — run graphs programmatically +- **`rivet serve`** — expose any graph as HTTP endpoint +- **Plugin ecosystem** — Anthropic, HuggingFace, MongoDB plugins + +**Key differentiator:** Desktop Electron app with visual AI chain builder + TypeScript runtime. The "graphs as YAML + TypeScript execution" approach is very relevant — could potentially embed Rivet's runtime in our app. + +**Relevance for Electron integration:** +- TypeScript runtime library for programmatic execution +- Already built as an Electron app — proven pattern +- YAML-based graph definitions could be stored/versioned +- Plugin architecture for extensibility +- **Confidence: 7/10, Reliability: 6/10** + +--- + +## 5. Protocol Standards & Ecosystem + +### 5.1 Protocol Landscape (2026) + +The AI agent ecosystem has converged on a layered protocol stack: + +| Protocol | Owner | Focus | Spec | +|----------|-------|-------|------| +| **MCP** (Model Context Protocol) | Anthropic / AAIF | Agent-to-Tool | Tool access, context | +| **A2A** (Agent-to-Agent) | Google / AAIF | Agent-to-Agent | Task delegation | +| **ACP** (Agent Communication Protocol) | IBM BeeAI / LF | Agent Communication | REST-based, merged into A2A Aug 2025 | +| **AG-UI** (Agent-to-User) | Community | Agent-to-User | Real-time interactivity | +| **AGNTCY** | Cisco / LF | Agent Infrastructure | Discovery, identity, security | + +**Sources:** [DEV.to MCP vs A2A](https://dev.to/pockit_tools/mcp-vs-a2a-the-complete-guide-to-ai-agent-protocols-in-2026-30li), [Agentic AI Foundation](https://intuitionlabs.ai/articles/agentic-ai-foundation-open-standards), [Pento MCP Review](https://www.pento.ai/blog/a-year-of-mcp-2025-review) + +**Key facts (March 2026):** +- MCP: 97M+ monthly SDK downloads (Python + TypeScript combined) +- AAIF (Agentic AI Foundation): Co-founded by OpenAI, Anthropic, Google, Microsoft, AWS, Block — hosts both MCP and A2A +- TypeScript MCP SDK: v1.27.1 (March 2026) +- A2A Agent Cards: `/.well-known/agent.json` for discovery +- Consensus architecture: MCP for tools, A2A for agents, AG-UI for humans + +**Key insight for our product:** "If your agents are all within the same organization, running in the same infrastructure — you don't need A2A. Use simpler orchestration. A2A's overhead isn't justified for single-org setups." ([Source](https://dev.to/pockit_tools/mcp-vs-a2a-the-complete-guide-to-ai-agent-protocols-in-2026-30li)) + +--- + +### 5.2 Semantic Router (Aurelio AI) + +- **URL:** https://github.com/aurelio-labs/semantic-router +- **License:** MIT +- **Language:** Python +- **Source:** [Aurelio AI](https://www.aurelio.ai/semantic-router), [GitHub](https://github.com/aurelio-labs/semantic-router) + +**What it is:** Superfast decision-making layer for LLMs and agents. Routes requests using semantic vector space instead of slow LLM calls. + +**Key capability:** Tool selection, guardrails, intent routing — all without LLM calls. Scales to thousands of tools. + +### 5.3 vLLM Semantic Router + +- **URL:** https://github.com/vllm-project/semantic-router +- **License:** Open source +- **Language:** Rust +- **Source:** [vLLM Blog](https://blog.vllm.ai/2026/01/05/vllm-sr-iris.html), [Red Hat](https://developers.redhat.com/articles/2025/09/11/vllm-semantic-router-improving-efficiency-ai-reasoning) + +**What it is:** System-level intelligent router for Mixture-of-Models. Routes queries to the best model based on complexity analysis. + +**v0.1 "Iris" release (January 2026):** Production-ready, 600+ PRs merged, 300+ issues, 50+ engineers. Supports OpenAI Responses API with conversation state for intelligent routing in multi-turn agent apps. + +**Key stats:** +10.2% accuracy on complex tasks, -47.1% latency, -48.5% token usage. + +--- + +## 6. Coding Agent Fleet Managers + +### 6.1 Angy + +- **URL:** Product Hunt (recent launch, ~1 week ago) +- **License:** Open source +- **Source:** [Product Hunt](https://www.producthunt.com/products/angy) + +**What it is:** Open-source fleet manager and IDE for Claude Code. Orchestrates a deterministic multi-phase pipeline (Plan -> Build -> Test) with adversarial verification. + +**Architecture:** +- **Adversarial Counterpart agent** that strictly verifies code +- **Git worktree isolation** for parallel agent execution +- **Scheduler** for running epics overnight +- **Multi-phase pipeline:** Architect -> Counterpart -> Build -> Test +- Self-bootstrapped after one day of initial work + +--- + +### 6.2 GitHub Agent HQ + +- **URL:** https://github.blog/news-insights/company-news/welcome-home-agents/ +- **Source:** [GitHub Blog](https://github.blog/news-insights/company-news/welcome-home-agents/), [Eficode](https://www.eficode.com/blog/why-github-agent-hq-matters-for-engineering-teams-in-2026) + +**What it is:** GitHub's platform for orchestrating AI agent fleets. Multi-agent support with Claude Code, Codex, and custom agents. + +**Architecture:** +- **Mission Control** — unified command center across GitHub, VS Code, mobile, CLI +- **Fleet of specialized agents** — security, testing, refactoring specialists +- **Multi-vendor:** Anthropic, OpenAI, Google, Cognition, xAI +- **Governance controls** — branch controls, identity, agent access policies +- **Squad** — coordinated AI teams inside repositories + +--- + +### 6.3 Hephaestus + +- **URL:** https://github.com/Ido-Levi/Hephaestus +- **License:** Open source (alpha) +- **Source:** [GitHub](https://github.com/Ido-Levi/Hephaestus), [HN](https://news.ycombinator.com/item?id=45796897) + +**What it is:** Semi-structured agentic framework where workflows build themselves as agents discover what needs to be done. + +**Architecture:** +- Define phase types (Analyze -> Implement -> Test), agents dynamically create tasks +- **Ticket-based coordination** — tickets flow through workflow carrying context +- **Guardian system** — LLM-powered coherence scoring for alignment checking +- **Parallel agents** in isolated Claude Code sessions +- Real-time observability + +**Key differentiator:** Emergent workflows — agents discover tasks rather than following predefined plans. Interesting alternative to rigid kanban task assignment. + +--- + +### 6.4 KAOS (Kubernetes Agent Orchestration System) + +- **URL:** https://github.com/axsaucedo/kaos +- **License:** Open source +- **Source:** [GitHub](https://github.com/axsaucedo/kaos), [HN](https://news.ycombinator.com/item?id=46688521) + +**What it is:** Kubernetes-native framework for deploying and orchestrating AI agents at scale. + +**Architecture:** +- **Golang control plane** — manages Agentic CRDs (Custom Resource Definitions) +- **Python data plane** — implements A2A, memory, tool/model management +- **React UI** — CRUD + debugging +- **PAIS** — enterprise wrapper for Pydantic AI with OpenAI-compatible HTTP API +- **A2A discovery** built in +- **OpenTelemetry** instrumentation + +**Key differentiator:** Kubernetes-native multi-agent system for hundreds/thousands of services. Production infrastructure approach. + +--- + +## 7. Python-First Frameworks (with TS relevance) + +### 7.1 BeeAI Framework (IBM) + +- **URL:** https://github.com/i-am-bee/beeai-framework +- **Stars:** 3k+ +- **License:** Open source (Linux Foundation governance) +- **Source:** [IBM Think](https://www.ibm.com/think/news/beeai-open-source-multiagent), [BeeAI Docs](https://framework.beeai.dev/) + +**What it is:** IBM's open-source framework for production-grade multi-agent systems. **Dual language: Python AND TypeScript with complete feature parity.** + +**Architecture:** +- 10+ LLM providers including Ollama, OpenAI, Watsonx.ai +- **MCP tool integration** +- **A2A protocol support** (ACP merged into A2A) +- **Agent Stack** — framework-agnostic deployment (BeeAI, LangGraph, CrewAI, custom) +- Built-in constraint enforcement and rule-based governance +- Each agent runs in its own container with resource limits +- OpenTelemetry observability + +**Key differentiator:** TypeScript with feature parity is rare among IBM projects. Linux Foundation governance ensures long-term stability. The Agent Stack deploy layer is uniquely framework-agnostic. + +**Relevance for Electron integration:** +- TypeScript SDK with full feature parity +- Framework-agnostic Agent Stack could deploy any agent +- MCP + A2A support aligns with protocol trends +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 7.2 Letta (formerly MemGPT) + +- **URL:** https://github.com/letta-ai/letta +- **Stars:** 16.2k+ +- **License:** Open source +- **Source:** [Letta site](https://www.letta.com/), [GitHub](https://github.com/letta-ai/letta) + +**What it is:** Platform for stateful agents with advanced memory that learn and self-improve over time. + +**Architecture:** +- **Self-editing memory** — agents manage their own memory blocks +- **Sleep-time compute** — agents "think" during downtime, rewrite memory +- **Skill learning** — agents learn new skills from experience +- **Letta Code** — #1 model-agnostic open source agent on Terminal-Bench +- **REST API + TypeScript SDK** +- Model-agnostic: OpenAI, Anthropic, local models + +**Key differentiator:** Memory-first architecture is unique. Sleep-time compute and skill learning are research-frontier features. TypeScript SDK available. + +**Relevance for Electron integration:** +- TypeScript SDK for client-side integration +- REST API for server-side +- Memory architecture could inform our agent context management +- **Confidence: 7/10, Reliability: 6/10** + +--- + +### 7.3 CAMEL-AI + +- **URL:** https://github.com/camel-ai/camel +- **Stars:** Growing (active research community) +- **License:** Apache 2.0 (code), CC BY NC 4.0 (datasets) +- **Source:** [CAMEL-AI site](https://www.camel-ai.org/), [GitHub](https://github.com/camel-ai/camel) + +**What it is:** The first open-source multi-agent framework, focused on dialog-driven collaboration and scaling laws of agents. + +**Architecture:** +- **Role-based agents** — structured conversations between assigned roles +- **OWL** — Optimized Workforce Learning, #1 on GAIA benchmark (69.09%) +- **OASIS** — simulations with 1M agents +- **MCPify** — project for MCP integration +- Accepted at NeurIPS 2025 + +**Key differentiator:** Research-first approach focused on scaling laws of multi-agent systems. OWL's GAIA benchmark performance is state-of-the-art. Python only. + +--- + +### 7.4 Julep AI + +- **URL:** https://github.com/julep-ai/julep +- **License:** Open source +- **Source:** [Julep site](https://julep.ai/), [GitHub](https://github.com/julep-ai/julep), [Temporal Blog](https://temporal.io/blog/julep-ai-future-ai-workflows) + +**What it is:** "Firebase for AI agents" — serverless platform for multi-step AI workflows. Persistent memory, modular workflows (YAML or code), built-in retries. + +**Status:** Hosted backend shut down December 31, 2025. Open-source self-hosting available. Team pivoted to **memory.store**. + +**Note:** Python and Node.js SDKs available, but future unclear given the pivot. + +--- + +### 7.5 ChatDev 2.0 + +- **URL:** https://github.com/OpenBMB/ChatDev +- **License:** Apache 2.0 +- **Source:** [GitHub](https://github.com/OpenBMB/ChatDev), [IBM](https://www.ibm.com/think/topics/chatdev) + +**What it is:** Zero-code multi-agent orchestration platform simulating a virtual software company. ChatDev 2.0 (January 2026) transforms rigid structures into flexible workflow systems. + +**Architecture:** +- **Visual canvas (Workflow)** — drag-and-drop multi-agent system design +- **Python SDK** (PyPI: chatdev) — run YAML workflows in Python +- **MacNet** — multi-agent collaboration networks for complex topologies +- **Puppeteer** — dynamic orchestration with RL-optimized agent sequencing +- FastAPI backend + Vue 3 frontend + +**Key differentiator:** NeurIPS 2025 accepted research, zero-code visual approach, software company simulation metaphor. Python + Vue only. + +--- + +### 7.6 Haystack (deepset) + +- **URL:** https://github.com/deepset-ai/haystack +- **Stars:** High (enterprise adoption: Airbus, NVIDIA, Comcast) +- **License:** Apache 2.0 +- **Source:** [Haystack site](https://haystack.deepset.ai/), [Haystack Docs](https://docs.haystack.deepset.ai/docs/agents) + +**What it is:** Open-source AI orchestration framework for production-ready LLM applications. Modular pipelines + agent workflows. + +**Architecture:** +- **Context engineering** — explicit control over retrieval, ranking, filtering, routing +- **Universal Agent** component with Chat Generator + tools +- **ComponentTool** — wrap any Haystack component as a callable tool +- **@tool decorator** — create tools from Python functions +- **Hayhooks** — expose pipelines/agents via HTTP or MCP +- **AgentSnapshot** — stepwise debugging with breakpoints +- Model-agnostic: OpenAI, Anthropic, Cohere, HuggingFace, Azure, Bedrock +- Latest: v2.25 (March 2026) + +**Key differentiator:** Enterprise-grade, context-engineering focused. The MCP exposure via Hayhooks means our app could consume Haystack agents as tools. + +--- + +### 7.7 ControlFlow (Prefect) -> Marvin + +- **URL:** https://github.com/PrefectHQ/ControlFlow (archived) +- **License:** Apache 2.0 +- **Source:** [Prefect Blog](https://www.prefect.io/blog/controlflow-intro) + +**What it is:** Task-centric AI workflow framework built on Prefect 3.0. **Archived** — merged into Marvin framework. + +**Key ideas (preserved in Marvin):** +- Tasks, Agents, Flows as core abstractions +- "AI agents are most effective when applied to small, well-defined tasks" +- Multi-agent collaboration strategies: Round-robin, Random, Moderated +- Every flow is a Prefect flow — full orchestration + observability + +--- + +## 8. Summary Matrix + +| Tool | Language | Stars | License | MCP | A2A | Multi-Agent | Electron-Ready | Maturity | +|------|----------|-------|---------|-----|-----|-------------|----------------|----------| +| **Mastra** | TypeScript | 22.3k | Apache 2.0 | Yes | -- | Yes | **Native** | Production | +| **Inngest AgentKit** | TypeScript | 793 | Apache 2.0 | Yes | -- | Yes (Networks) | **Native** | Beta | +| **VoltAgent** | TypeScript | 5.1k | MIT | Yes | -- | Yes (Chain API) | **Native** | Early | +| **HazelJS** | TypeScript | Small | Apache 2.0 | -- | -- | Yes (AgentGraph) | **Native** | Alpha | +| **Agentica** | TypeScript | Small | MIT | Yes | -- | No | **Native** | Beta | +| **Strands (AWS)** | Python+TS | 14M DL | Apache 2.0 | Yes | -- | Yes (Swarm) | TS SDK | Preview | +| **OpenAI Agents SDK** | TypeScript | 2.1k | MIT | Yes | -- | Yes (Handoffs) | **Native** | GA | +| **Google ADK TS** | TypeScript | 581 | Apache 2.0 | Yes | Yes | Yes | **Native** | Early | +| **BeeAI** | Python+TS | 3k | Open (LF) | Yes | Yes | Yes | TS SDK | Production | +| **AgentGateway** | Rust | 2k | Open (LF) | Yes | Yes | -- (infra) | Sidecar | v1.0 | +| **Temporal** | Multi | 13k | MIT | -- | -- | -- (infra) | TS SDK | Production | +| **Trigger.dev** | TypeScript | 13.9k | Apache 2.0 | Yes | -- | Yes | Server-side | v4 | +| **Hatchet** | Multi | 4.5k | MIT | -- | -- | -- (infra) | TS SDK | Production | +| **Dify** | Python | 129.8k | Apache 2.0 | Yes | -- | Yes | REST API | Production | +| **n8n** | TypeScript | 180.7k | Fair-code | Yes | -- | Yes (basic) | Heavy | Production | +| **Rivet** | TypeScript | 3.9k | Open | -- | -- | -- | **Electron app** | v4.1 | +| **Letta** | Python+TS | 16.2k | Open | -- | -- | -- | TS SDK | Production | +| **CAMEL-AI** | Python | Growing | Apache 2.0 | -- | -- | Yes | -- | Research | +| **ChatDev 2.0** | Python | Growing | Apache 2.0 | -- | -- | Yes | -- | v2.0 | +| **Haystack** | Python | High | Apache 2.0 | Yes | -- | Yes | REST/MCP | v2.25 | + +--- + +## 9. Recommendations for Claude Agent Teams UI + +### Tier 1: Most Relevant for Integration (TypeScript-native, embeddable) + +1. **Mastra** — The most mature TS agent framework. Could serve as orchestration backend for agent workflows, multi-model routing, and memory management. Proven at scale (Replit, PayPal). + +2. **Inngest AgentKit** — Lightweight multi-agent networks with durable execution. The Agent -> Network -> Router -> State model maps well to our team/agent/task architecture. + +3. **OpenAI Agents SDK (TS)** — If we want to support OpenAI models natively. Handoff mechanism is clean for agent-to-agent delegation. + +4. **VoltAgent** — Observability-first approach complements our session analysis. Chain API for multi-agent workflows is well-designed. + +### Tier 2: Protocol & Infrastructure Integration + +5. **AgentGateway** — Could be bundled as a sidecar process. Handles MCP/A2A protocol routing, OpenAPI-to-MCP translation, multi-tenancy. + +6. **MCP Gateway Registry** — Solves MCP server governance for enterprise deployments. + +7. **Rivet** — TypeScript runtime library for visual AI chain execution. Already an Electron app. + +### Tier 3: External Services (consume via API/MCP) + +8. **Dify** — Expose visual workflows as MCP servers that our app consumes. +9. **Trigger.dev** — Durable execution backend via MCP server integration. +10. **Hatchet** — Lightweight durable execution (just PostgreSQL). + +### Key Architectural Insight + +The emerging pattern for 2026 is a **layered architecture**: +- **Protocol layer:** MCP (tools) + A2A (agents) + AG-UI (humans) +- **Execution layer:** Durable workflows (Temporal/Hatchet/Inngest) +- **Agent layer:** Framework-specific (Mastra/AgentKit/custom) +- **Orchestration layer:** Fleet management (our kanban board / Agent HQ / Hephaestus) +- **Gateway layer:** AgentGateway for routing, security, observability + +Our product (Claude Agent Teams UI) sits at the **orchestration layer** — the kanban-based fleet management interface. The key opportunity is to become framework-agnostic by integrating with the protocol layer (MCP/A2A) and supporting multiple agent frameworks underneath. + +### Unique Competitive Advantages We Have + +Based on this research, no tool combines ALL of: +1. Kanban-based task management (visual orchestration) +2. Multi-agent team coordination with real-time communication +3. Code review (diff view) per task +4. Deep session analysis (bash commands, reasoning, tokens) +5. Desktop-native (Electron) with zero-setup + +The closest competitors are GitHub Agent HQ (platform-level, not desktop) and Angy (fleet manager, but IDE-focused not kanban). Our kanban + code review + session analysis combination remains unique. diff --git a/docs/research/ai-orchestration-tools.md b/docs/research/ai-orchestration-tools.md new file mode 100644 index 00000000..9928836b --- /dev/null +++ b/docs/research/ai-orchestration-tools.md @@ -0,0 +1,550 @@ +# AI Agent Orchestration Tools & Frameworks (March 2026) + +> Research date: 2026-03-24 +> Focus: Multi-provider AI coding agent orchestration — tools that coordinate Claude Code, Codex CLI, Gemini CLI, and other AI agents together. + +## Executive Summary + +The multi-agent AI orchestration market has exploded in 2025-2026. Gartner reports a **1,445% surge** in multi-agent system inquiries from Q1 2024 to Q2 2025. The AI agent market reached **$7.84B in 2025**, projected to hit **$52.62B by 2030** (CAGR 46.3%). + +The landscape splits into three distinct categories: +1. **Desktop orchestrators** — Electron/Tauri apps managing parallel coding agents with kanban boards, diff viewers, git worktree isolation +2. **CLI/framework orchestrators** — Command-line tools and Python/TypeScript frameworks for multi-agent coordination +3. **General-purpose multi-agent frameworks** — Provider-agnostic frameworks for building any multi-agent system (not coding-specific) + +**Key finding for our project:** Multiple direct competitors have emerged with kanban boards + multi-agent orchestration (Vibe Kanban, Dorothy, Mozzie). However, none combine all of: multi-provider agent support + kanban + code review + team communication + Electron desktop app in the way Claude Agent Teams UI does. + +--- + +## Category 1: Desktop Orchestrators (Most Relevant to Our Project) + +### 1.1 Vibe Kanban (BloopAI) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) | +| **Stars** | ~23,700 | +| **License** | Open source (free) | +| **Tech Stack** | Rust (backend) + TypeScript/React (frontend) | +| **AI Providers** | Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, Qwen Code (10+) | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Cross-platform orchestration platform (CLI + web UI) with kanban board. Each agent gets its own git worktree and branch. Implements MCP both as client and server — the kanban board itself becomes an API for AI agents. + +**Key features:** +- Kanban board with drag-and-drop task management +- Parallel agent execution in isolated workspaces +- Built-in diff review with inline comments +- Built-in browser preview with devtools +- MCP server — other agents can create tasks, move cards, read board status +- PR creation and merge from UI +- Install via `npx vibe-kanban` + +**Relevance to us:** **DIRECT COMPETITOR.** Has kanban + multi-agent + diff review. Key differences: no team communication/messaging between agents, no session analysis, no context monitoring. Uses Rust backend (not Electron). + +--- + +### 1.2 Dorothy + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/Charlie85270/Dorothy](https://github.com/Charlie85270/Dorothy) | +| **Website** | [dorothyai.app](https://dorothyai.app/) | +| **License** | Open source | +| **Tech Stack** | Electron + React/Next.js | +| **AI Providers** | Claude Code, Codex, Gemini CLI | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Electron desktop app with isolated PTY terminal sessions per agent. Features a "Super Agent" orchestrator that programmatically controls all other agents via MCP tools. + +**Key features:** +- Kanban board with drag-and-drop, agents auto-pick work by skill +- 5 MCP servers (40+ tools) for programmatic agent control +- Super Agent meta-orchestrator that delegates across agent pool +- GitHub, JIRA, Telegram, Slack integrations +- Google Workspace integration (Gmail, Drive, Sheets, Calendar) +- Community skill plugins from skills.sh +- 3D animated agent visualization +- Agent automations (trigger on GitHub PRs, issues, events) +- Scheduling and recurring agent tasks + +**Relevance to us:** **DIRECT COMPETITOR.** Electron + kanban + multi-agent + MCP. Most similar to our architecture. Lacks: team-level communication, deep session analysis, context token tracking, structured code review workflow. + +--- + +### 1.3 Superset + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/superset-sh/superset](https://github.com/superset-sh/superset) | +| **Website** | [superset.sh](https://superset.sh/) | +| **Stars** | ~7,800 | +| **License** | Elastic License 2.0 (ELv2) — NOT MIT/Apache | +| **Tech Stack** | Electron + React + xterm.js + TailwindCSS v4, Bun + Turborepo | +| **AI Providers** | Claude Code, Codex, OpenCode, Cursor Agent — any CLI agent | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Electron desktop terminal environment. Each task gets its own git worktree. Built-in diff viewer and editor. Same terminal stack as VS Code (xterm.js). + +**Key features:** +- Run 10+ agents simultaneously +- Git worktree isolation per task +- Built-in diff viewer +- Workspace presets (automate env setup, deps) +- One-click open in external IDE +- Agent status monitoring and notifications + +**Relevance to us:** Competitor in the parallel-agent-desktop space. Less feature-rich (no kanban, no team messaging, no code review workflow). More of a "terminal multiplexer for agents" than a full management platform. + +--- + +### 1.4 Mozzie + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/usemozzie/mozzie](https://github.com/usemozzie/mozzie) | +| **License** | Open source | +| **Tech Stack** | Tauri (Rust) + Node + pnpm | +| **AI Providers** | Claude Code, Gemini CLI, Codex CLI, custom scripts | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Tauri desktop app with LLM orchestrator. Agents communicate via ACP (Agent Communication Protocol) over stdio. Persistent orchestrator conversation history. + +**Key features:** +- LLM orchestrator that creates work items, sets dependencies, assigns agents +- Git worktree isolation per work item +- Dependency graph with cycle detection +- Sub-work-items with stacked branches +- Review workflow (approve to push, reject with feedback) +- Live streaming of agent output with tool-call visualization +- Agents learn from rejection history + +**Relevance to us:** Competitor. Tauri-based (lighter than Electron). Has dependency management and review workflow. No kanban board per se, more of a work-item queue. + +--- + +### 1.5 Parallel Code + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/johannesjo/parallel-code](https://github.com/johannesjo/parallel-code) | +| **License** | MIT | +| **AI Providers** | Claude Code, Codex CLI, Gemini CLI | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Desktop app with automatic git worktree creation per task. Keyboard-first design. + +**Key features:** +- Automatic branch + worktree per task +- 5+ agents in parallel, zero conflicts +- Unified session view +- Built-in diff viewer with one-click merge +- Mobile monitoring via QR code (Wi-Fi/Tailscale) +- Keyboard-first, mouse optional + +**Relevance to us:** Simpler competitor focused on parallel execution + diff review. No kanban, no team communication. + +--- + +## Category 2: CLI/Framework Orchestrators for Coding Agents + +### 2.1 MCO (Multi-CLI Orchestrator) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/mco-org/mco](https://github.com/mco-org/mco) | +| **License** | Open source | +| **Language** | TypeScript/Node | +| **AI Providers** | Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Architecture:** Neutral orchestration layer. Dispatches prompts to multiple agent CLIs in parallel, aggregates results, returns structured output (JSON, SARIF, PR-ready Markdown). No vendor lock-in. + +**Key concept:** "Work like a Tech Lead" — assign one task to multiple agents, run in parallel, compare outcomes. Designed to be called by any IDE or agent (Cursor, Trae, Copilot, Windsurf). + +**Integration potential:** Could be used as a backend dispatch layer. MCO handles the multi-agent fan-out; our UI handles the visualization and management. + +--- + +### 2.2 Agent Orchestrator (ComposioHQ) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/ComposioHQ/agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) | +| **Stars** | ~4,500 | +| **License** | MIT | +| **Language** | TypeScript | +| **AI Providers** | Claude Code, Codex, Aider (agent-agnostic plugin system) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Plugin-based orchestrator managing fleets of coding agents. 8 pluggable abstraction slots: agent, runtime, tracker, reviewer, etc. Each agent gets own git worktree, branch, and PR. + +**Key features:** +- Agent-agnostic (Claude Code, Codex, Aider) +- Runtime-agnostic (tmux, Docker) +- Tracker-agnostic (GitHub, Linear) +- Auto-fix CI failures and address review comments +- Centralized dashboard for monitoring +- 100% AI co-authored codebase (impressive dogfooding) +- 30 concurrent agents at peak + +**Impressive stat:** 8 days from first commit to 43K lines of TypeScript, 91 commits, 61 PRs merged, 84% of PRs created by AI agent sessions. + +--- + +### 2.3 AWS CLI Agent Orchestrator (CAO) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/awslabs/cli-agent-orchestrator](https://github.com/awslabs/cli-agent-orchestrator) | +| **License** | Open source | +| **Language** | Python | +| **AI Providers** | Amazon Q CLI, Claude Code (Codex CLI, Gemini CLI, Qwen CLI planned) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Hierarchical multi-agent system with Supervisor Agent coordinating Worker Agents. Each agent in isolated tmux session. Communication via MCP servers. Local HTTP server processes orchestration requests. + +**Orchestration patterns:** +- Handoff (synchronous task transfer) +- Assign (async parallel execution) +- Send Message (direct agent communication) +- Flow — scheduled cron-like runs + +**Caveat:** Supervisor runs on Amazon Bedrock — requires AWS credentials and account. Open source code but can't run without AWS infrastructure. + +--- + +### 2.4 MetaSwarm + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/dsifry/metaswarm](https://github.com/dsifry/metaswarm) | +| **License** | Open source | +| **Language** | TypeScript/Node | +| **AI Providers** | Claude Code, Gemini CLI, Codex CLI | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Architecture:** Self-improving multi-agent orchestration with 18 specialized agent personas, 13 skills, 15 commands. 9-phase workflow from issue to merged PR. + +**Key features:** +- Recursive orchestration (swarm of swarms) +- Cross-model review (writer reviewed by different AI model) +- Per-task and per-session USD budget circuit breakers +- TDD enforcement, quality gates +- Git worktree isolation with sandbox protection +- Auto-detects Team Mode when multiple sessions active +- Install via `npx metaswarm init` + +--- + +### 2.5 Overstory + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/jayminwest/overstory](https://github.com/jayminwest/overstory) | +| **License** | Open source | +| **Language** | TypeScript (Bun) | +| **AI Providers** | Claude Code, Pi, Gemini CLI, Aider, Goose, Amp (11 runtime adapters) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Pluggable `AgentRuntime` interface. Tmux isolation per agent in git worktrees. SQLite WAL-mode mail system for inter-agent messaging (~1-5ms per query). Two-layer instruction system (Base + per-task Overlay). + +**Key features:** +- 11 runtime adapters +- FIFO merge queue with 4-tier conflict resolution +- Tiered watchdog system (mechanical daemon + AI triage + monitor agent) +- Instruction overlays for orchestrated workers +- Honest self-critique in project docs (refreshing transparency) + +--- + +### 2.6 Claude Octopus + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/nyldn/claude-octopus](https://github.com/nyldn/claude-octopus) | +| **License** | Open source | +| **AI Providers** | Codex, Gemini, Claude, Perplexity, OpenRouter, Copilot, Qwen, Ollama (8 providers) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Multi-LLM orchestration plugin for Claude Code. 75% consensus gate catches disagreements before production. 32 specialized personas, 47 commands, 50 skills. Zero providers required to start — add them one at a time. + +--- + +### 2.7 agtx + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/fynnfluegge/agtx](https://github.com/fynnfluegge/agtx) | +| **License** | Open source | +| **AI Providers** | Claude Code, Codex, Gemini CLI, OpenCode, Cursor | +| **Reliability** | 6/10 | +| **Confidence** | 6/10 | + +**Architecture:** Multi-session AI coding terminal manager. Orchestrator agent picks up tasks, plans, and delegates to multiple coding agents running in parallel. + +--- + +## Category 3: General-Purpose Multi-Agent Frameworks + +### 3.1 CrewAI + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/crewAIInc/crewAI](https://github.com/crewAIInc/crewAI) | +| **Stars** | ~45,900 | +| **License** | MIT | +| **Language** | Python | +| **AI Providers** | OpenAI, Anthropic, Gemini, Ollama, any via LiteLLM | +| **Maturity** | Production-ready, 100K+ certified developers | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**Architecture:** Role-based metaphor (role, goal, backstory per agent). Three process types: sequential, hierarchical, consensual. Native MCP and A2A support. Two approaches: Crews (autonomy) and Flows (enterprise production). + +**Electron integration potential:** Python-based, so would need a subprocess/API bridge. Not designed for desktop UI integration but could serve as an orchestration backend. + +--- + +### 3.2 Microsoft Agent Framework (AutoGen + Semantic Kernel) + +| Attribute | Details | +|-----------|---------| +| **URL** | [learn.microsoft.com/en-us/agent-framework](https://learn.microsoft.com/en-us/agent-framework/overview/) | +| **Stars** | AutoGen: ~52,000 | +| **License** | Open source (MIT) | +| **Language** | Python, .NET | +| **AI Providers** | OpenAI, Azure OpenAI, Anthropic, Gemini, local models | +| **Maturity** | GA targeted end Q1 2026 | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Architecture:** Unified SDK + runtime merging AutoGen + Semantic Kernel. Orchestration patterns: sequential, concurrent, group chat, handoff, Magentic (dynamic task ledger). Event-driven core, async-first. + +**Electron integration potential:** Primarily Python/.NET. Could use as a backend runtime via API. + +--- + +### 3.3 Agno + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/agno-agi/agno](https://github.com/agno-agi/agno) | +| **Stars** | ~38,900 | +| **License** | Apache-2.0 | +| **Language** | Python | +| **AI Providers** | OpenAI, Anthropic, Groq, and many more | +| **Maturity** | Production-ready (AgentOS + FastAPI runtime) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Architecture:** Three-layer design: framework (agents, teams, workflows), runtime (stateless FastAPI backends), monitoring. Claims 529x faster instantiation than LangGraph. Teams with automatic agent-to-agent communication, context passing, result aggregation. + +**Electron integration potential:** FastAPI backend makes it easy to integrate via HTTP API. + +--- + +### 3.4 OpenAI Agents SDK (successor to Swarm) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/openai/openai-agents-python](https://github.com/openai/openai-agents-python) | +| **License** | MIT | +| **Language** | Python | +| **AI Providers** | OpenAI + 100+ LLMs via provider-agnostic design | +| **Maturity** | Production-ready (launched March 2025) | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Core primitives: Agents, Handoffs, Guardrails, Function tools, MCP server tool calling, Sessions, Tracing. Handoff pattern: agents transfer control explicitly, carrying conversation context. Built-in MCP integration. + +--- + +### 3.5 LangGraph (by LangChain) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) | +| **License** | MIT | +| **Language** | Python, TypeScript | +| **AI Providers** | Model-agnostic (plug different LLMs into different nodes) | +| **Maturity** | Production-ready, LangSmith observability | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Graph-based design. Each agent is a node maintaining its own state. Conditional edges, multi-team coordination, hierarchical control. Supervisor nodes for scalable orchestration. + +--- + +### 3.6 AWS Agent Squad (formerly Multi-Agent Orchestrator) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/awslabs/agent-squad](https://github.com/awslabs/agent-squad) | +| **License** | Open source | +| **Language** | Python, TypeScript (dual) | +| **AI Providers** | AWS Bedrock, extensible | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Intelligent intent classification routes queries dynamically. Streaming + non-streaming support. Context management across agents. Universal deployment (Lambda to any cloud). + +--- + +### 3.7 Google ADK (Agent Development Kit) + +| Attribute | Details | +|-----------|---------| +| **URL** | [cloud.google.com](https://cloud.google.com/blog/products/ai-machine-learning/unlock-ai-agent-collaboration-convert-adk-agents-for-a2a) | +| **License** | Open source | +| **Language** | Python | +| **AI Providers** | Gemini (primary), extensible | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Hierarchical agent tree. Native A2A protocol support — agents from different frameworks can discover and invoke each other. + +--- + +### 3.8 OpenAI Symphony (New — March 2026) + +| Attribute | Details | +|-----------|---------| +| **URL** | See [Medium article](https://medium.com/@georgethomasm_89397/openai-symphony-the-new-orchestration-framework-for-multi-agent-systems-2ec991ee74cc) | +| **License** | Open source | +| **Language** | Python | +| **Maturity** | Very early (released March 5, 2026) | +| **Reliability** | 4/10 | +| **Confidence** | 5/10 | + +**Architecture:** Hierarchical delegation, iterative refinement, composable workflows. Checkpoint-based recovery — if agent fails mid-execution, workflow resumes from last checkpoint. Documentation sparse, community small, but growing. + +--- + +## Key Protocols & Standards + +### Google A2A (Agent-to-Agent Protocol) + +| Attribute | Details | +|-----------|---------| +| **URL** | [a2a-protocol.org](https://a2a-protocol.org/latest/) | +| **GitHub** | [github.com/a2aproject/A2A](https://github.com/a2aproject/A2A) | +| **Status** | v0.3 (July 2025), donated to Linux Foundation | +| **Supporters** | 150+ organizations (Google, Atlassian, Salesforce, SAP, etc.) | +| **Confidence** | 9/10 | + +**Purpose:** Agent-to-agent communication standard. Complementary to MCP (agent-to-tool). Agent Cards (JSON) for capability discovery. HTTP + gRPC transport. Becoming the de facto interop standard. + +### Anthropic MCP (Model Context Protocol) + +Already integrated into our project. MCP = agent-to-tool communication. A2A = agent-to-agent communication. The two are complementary. + +--- + +## Comparison Matrix: Desktop Orchestrators + +| Feature | **Our App** | **Vibe Kanban** | **Dorothy** | **Superset** | **Mozzie** | +|---------|------------|-----------------|-------------|--------------|------------| +| **Kanban board** | Yes | Yes | Yes | No | No | +| **Multi-provider agents** | Claude only* | 10+ agents | 3 agents | Any CLI | 3+ agents | +| **Code review / diff** | Yes | Yes | No | Yes | Yes | +| **Team communication** | Yes | No | Via Super Agent | No | No | +| **Session analysis** | Yes (deep) | No | No | No | No | +| **Context monitoring** | Yes | No | No | No | No | +| **MCP integration** | Yes | Yes (client+server) | Yes (5 servers) | No | ACP | +| **Agent-to-agent messaging** | Yes | Via MCP | Via Super Agent | No | Via ACP | +| **Dependency graph** | No | No | No | No | Yes | +| **External integrations** | No | GitHub | GitHub, JIRA, Slack, Telegram | IDE integration | No | +| **Tech stack** | Electron/React | Rust/React | Electron/React | Electron/React | Tauri | +| **License** | MIT | Free/OSS | OSS | ELv2 | OSS | +| **GitHub stars** | ~small | ~23,700 | Unknown | ~7,800 | Unknown | + +*Currently Claude-only, but the architecture could support multi-provider agents. + +--- + +## Strategic Recommendations + +### Immediate Opportunities + +1. **Multi-provider support is the #1 gap.** Every competitor now supports Claude + Codex + Gemini. Our single-provider approach is a significant limitation. Priority: HIGH. + +2. **MCP server exposure.** Dorothy and Vibe Kanban expose their kanban board as an MCP server — agents can programmatically create tasks, move cards, check status. This is a powerful pattern we should adopt. + +3. **A2A protocol awareness.** The A2A standard (150+ orgs, Linux Foundation) is becoming the agent-to-agent interop standard. We should monitor and potentially implement it. + +### Integration Paths for Multi-Provider Support + +| Approach | Description | Effort | Reliability | +|----------|-------------|--------|-------------| +| **Direct CLI integration** | Spawn Codex CLI / Gemini CLI alongside Claude Code in separate processes | Medium | 8/10 | +| **MCO as dispatch layer** | Use MCO to fan out tasks across multiple agent CLIs | Low | 7/10 | +| **Plugin architecture** | Build pluggable AgentRuntime interface (like Overstory) | High | 9/10 | +| **A2A protocol** | Implement A2A for cross-agent communication | High | 7/10 | + +### Unique Differentiators We Should Protect + +1. **Deep session analysis** (bash commands, reasoning, subprocesses) — nobody else has this +2. **Context monitoring** (token usage by category) — unique feature +3. **Team communication model** (lead + teammates with direct messaging) — only Dorothy's Super Agent comes close +4. **Post-compact context recovery** — unique +5. **Code review workflow** (accept/reject/comment per task) — Vibe Kanban is closest competitor here + +### Tools Worth Investigating Further + +1. **Vibe Kanban** — most direct competitor, 23.7K stars, Rust backend, mature feature set +2. **Dorothy** — Electron architecture closest to ours, MCP-heavy, good integration model +3. **Agent Orchestrator (ComposioHQ)** — plugin architecture is excellent, could inspire our multi-provider design +4. **MCO** — lightweight dispatch layer we could integrate as-is +5. **Overstory** — SQLite mail system for inter-agent messaging is elegant + +--- + +## Curated Resource Lists + +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) — Comprehensive list of orchestration tools +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) — 80+ CLI coding agents + orchestration harnesses +- [awesome-ai-agents-2026](https://github.com/caramaschiHG/awesome-ai-agents-2026) — 300+ resources across 20+ categories + +--- + +## Sources + +- [Top 5 Open-Source Agentic AI Frameworks in 2026](https://aimultiple.com/agentic-frameworks) +- [Top 9 AI Agent Frameworks — Shakudo](https://www.shakudo.io/blog/top-9-ai-agent-frameworks) +- [Best Open Source Frameworks for AI Agents — Firecrawl](https://www.firecrawl.dev/blog/best-open-source-agent-frameworks) +- [Microsoft Agent Framework Announcement](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/) +- [OpenAI Symphony — Medium](https://medium.com/@georgethomasm_89397/openai-symphony-the-new-orchestration-framework-for-multi-agent-systems-2ec991ee74cc) +- [CrewAI Open Source](https://crewai.com/open-source) +- [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) +- [AWS CLI Agent Orchestrator](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) +- [Google A2A Protocol](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [A2A Protocol v0.3 Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Warp Oz Platform](https://www.warp.dev/blog/oz-orchestration-platform-cloud-agents) +- [Vibe Kanban](https://vibekanban.com/) +- [Dorothy AI](https://dorothyai.app/) +- [Superset IDE](https://superset.sh/) +- [MCO — mco-org/mco](https://github.com/mco-org/mco) +- [Agent Orchestrator — ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator) +- [MetaSwarm](https://github.com/dsifry/metaswarm) +- [Overstory](https://github.com/jayminwest/overstory) +- [Claude Octopus](https://github.com/nyldn/claude-octopus) +- [Mozzie](https://github.com/usemozzie/mozzie) +- [Parallel Code](https://github.com/johannesjo/parallel-code) +- [Orchestral AI Paper](https://arxiv.org/abs/2601.02577) +- [LLM Orchestration 2026 — AIMultiple](https://aimultiple.com/llm-orchestration) +- [Multi-Agent Frameworks 2026 — GuruSup](https://gurusup.com/blog/best-multi-agent-frameworks-2026) +- [Agno Framework](https://github.com/agno-agi/agno) +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) diff --git a/docs/research/best-abstraction-for-electron.md b/docs/research/best-abstraction-for-electron.md new file mode 100644 index 00000000..9af717cc --- /dev/null +++ b/docs/research/best-abstraction-for-electron.md @@ -0,0 +1,726 @@ +# Best Abstraction Tool for Multi-Provider Agent Support in Electron + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: actual source analysis of `TeamProvisioningService.ts` (7,982 LOC), `childProcess.ts`, `TeamMcpConfigBuilder.ts`, `PtyTerminalService.ts`, `agent-teams-controller/`, and prior research in `docs/research/` + +--- + +## Context: What We Have Today + +Our Electron app (40.x) manages Claude Code CLI processes via: + +| Component | File | Role | +|-----------|------|------| +| `spawnCli()` | `src/main/utils/childProcess.ts` | child_process.spawn wrapper with Windows EINVAL fallback, injects `CLI_ENV_DEFAULTS` | +| `TeamProvisioningService` | `src/main/services/team/TeamProvisioningService.ts` | 7,982 LOC monolith: process lifecycle, stream-json NDJSON parsing, prompt engineering, stall watchdog, tool approval relay | +| `ClaudeBinaryResolver` | `src/main/services/team/ClaudeBinaryResolver.ts` | Resolves `claude` binary across PATH, NVM, platform dirs | +| `TeamMcpConfigBuilder` | `src/main/services/team/TeamMcpConfigBuilder.ts` | Builds `--mcp-config` JSON for every spawned process | +| `PtyTerminalService` | `src/main/services/infrastructure/PtyTerminalService.ts` | node-pty for embedded terminal (used separately, NOT for agent processes) | +| `agent-teams-controller` | `agent-teams-controller/` | Provider-agnostic file CRUD (tasks, kanban, inbox, reviews) | +| `killTeamProcess()` | TeamProvisioningService | Uses SIGKILL to prevent Claude CLI SIGTERM cleanup deleting team files | + +**Current protocol**: Claude CLI `--input-format stream-json --output-format stream-json` — proprietary NDJSON with types: `user`, `assistant`, `control_request`, `result`, `system`. + +**Current coupling**: 9/10 to Claude Code CLI (see `best-integration-approach.md` for full coupling map). + +--- + +## Two Distinct Needs + +### Level 1: CLI Agent Process Management +Spawn external CLI agents (Claude Code, Codex CLI, Gemini CLI, Goose) as child processes, each with its own protocol, binary resolution, health monitoring, and MCP config. + +### Level 2: Programmatic LLM API Calls +Call LLM APIs directly for lightweight tasks (code review bot, triage bot, task planning, MCP tool calling). No CLI process — just HTTP to provider APIs. + +These are **fundamentally different problems** and should use **different solutions**. + +--- + +## Level 1: CLI Agent Process Management + +### The Candidates + +#### Option A: Own Adapter Pattern (Overstory-style) +**Reliability: 9/10 | Confidence: 9/10** + +Build a thin `AgentCliAdapter` interface with per-CLI implementations. + +```typescript +// src/main/services/agent/AgentCliAdapter.ts +export interface AgentCliAdapter { + readonly providerId: string; // 'claude' | 'codex' | 'gemini' | 'goose' + + /** Resolve binary path on this machine */ + resolveBinary(): Promise; + + /** Build spawn args for creating/launching a team */ + buildSpawnArgs(request: AgentSpawnRequest): string[]; + + /** Build env vars for the spawned process */ + buildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv; + + /** Parse a line of stdout. Returns typed event or null (skip). */ + parseStdoutLine(line: string): AgentOutputEvent | null; + + /** Format a user message for stdin */ + formatUserMessage(text: string): string; + + /** Process exit semantics: what does exit code mean? */ + interpretExitCode(code: number | null): 'success' | 'error' | 'killed'; + + /** Kill semantics: SIGTERM vs SIGKILL */ + killProcess(child: ChildProcess): void; + + /** Whether this CLI needs MCP config file */ + needsMcpConfig: boolean; + + /** Build MCP config in the format this CLI expects */ + buildMcpConfig?(servers: Record): object; +} +``` + +Per-provider implementations: + +```typescript +// src/main/services/agent/adapters/ClaudeCliAdapter.ts +export class ClaudeCliAdapter implements AgentCliAdapter { + readonly providerId = 'claude'; + readonly needsMcpConfig = true; + + async resolveBinary(): Promise { + return new ClaudeBinaryResolver().resolve(); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user,project,local', + '--mcp-config', request.mcpConfigPath!, + '--disallowedTools', 'TeamDelete,TodoWrite', + ...(request.skipPermissions + ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] + : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), + ...(request.model ? ['--model', request.model] : []), + ]; + } + + buildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...base, CLAUDE_HOOK_JUDGE_MODE: 'true' }; + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + const msg = JSON.parse(line); + // Existing 60+ branch logic from handleStreamJsonMessage() + switch (msg.type) { + case 'assistant': return { kind: 'text', content: extractText(msg) }; + case 'result': return { kind: 'result', success: msg.subtype !== 'error' }; + case 'control_request': return { kind: 'approval', request: msg }; + // ... etc + } + } + + formatUserMessage(text: string): string { + return JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'; + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGKILL'); // SIGKILL to prevent cleanup + } +} +``` + +```typescript +// src/main/services/agent/adapters/CodexCliAdapter.ts +export class CodexCliAdapter implements AgentCliAdapter { + readonly providerId = 'codex'; + readonly needsMcpConfig = false; // Codex uses MCP differently + + async resolveBinary(): Promise { + // which codex + return resolveWhich('codex'); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return ['app-server']; // JSON-RPC mode + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + // JSON-RPC notification parsing + const msg = JSON.parse(line); + if (msg.method === 'item/agentMessage/delta') { + return { kind: 'text_delta', content: msg.params.delta }; + } + // ... + } + + formatUserMessage(text: string): string { + // JSON-RPC request for turn/start + return JSON.stringify({ + jsonrpc: '2.0', id: nextId(), + method: 'turn/start', + params: { message: text }, + }) + '\n'; + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGTERM'); // Codex handles SIGTERM gracefully + } +} +``` + +```typescript +// src/main/services/agent/adapters/GeminiCliAdapter.ts +export class GeminiCliAdapter implements AgentCliAdapter { + readonly providerId = 'gemini'; + readonly needsMcpConfig = false; + + async resolveBinary(): Promise { + return resolveWhich('gemini'); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return [ + '--output-format', 'stream-json', + '-p', request.prompt, + ]; + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + // Gemini NDJSON events + const event = JSON.parse(line); + // ... + } + + formatUserMessage(text: string): string { + // Gemini headless doesn't support multi-turn stdin in stream-json + // (one-shot with -p flag). For multi-turn, need new process per turn. + throw new Error('Gemini CLI does not support multi-turn stdin'); + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGTERM'); + } +} +``` + +**Pros:** +- Zero new dependencies +- Perfectly fits existing `spawnCli()` / `killProcessTree()` infrastructure +- Each adapter is ~100-200 LOC — easy to test in isolation +- Can be extracted incrementally from the existing TeamProvisioningService +- No framework overhead in the Electron main process +- Each CLI's quirks handled explicitly (Claude SIGKILL vs Codex SIGTERM, stream-json vs JSON-RPC) + +**Cons:** +- We write the adapter code ourselves (~500 LOC total for 4 adapters) +- No built-in CLI discovery / health check framework + +**Effort**: ~800 LOC (interface + 4 adapters + factory), 3-5 days + +--- + +#### Option B: node-pty Based Approach +**Reliability: 5/10 | Confidence: 4/10** + +Use pseudo-terminal for all CLI agents (captures raw terminal output). + +```typescript +import * as pty from 'node-pty'; + +const proc = pty.spawn('claude', ['--verbose'], { + name: 'xterm-256color', + cols: 120, rows: 40, + cwd: projectPath, + env: process.env, +}); + +proc.onData((data) => { + // Problem: raw terminal output with ANSI codes, cursor movement, etc. + // We'd need to strip all that to parse structured JSON +}); +``` + +**Pros:** +- Already have `node-pty` in dependencies (for embedded terminal) +- Works with any CLI that has a TUI mode + +**Cons:** +- node-pty is a native addon requiring electron-rebuild (fragile across platforms) +- All CLIs output ANSI escape codes in TTY mode — parsing structured data from raw terminal output is extremely unreliable +- We ALREADY use stream-json/JSON-RPC specifically to AVOID the TTY problem +- Memory overhead of full PTY per agent process +- Claude Code, Codex, and Gemini all have headless/programmatic modes — PTY is the WRONG abstraction + +**Verdict: REJECT.** PTY is for interactive terminals, not programmatic agent management. We already learned this — `PtyTerminalService` is used only for the embedded terminal, not for agent processes. + +--- + +#### Option C: MCO / Third-Party Orchestrator Library +**Reliability: 3/10 | Confidence: 3/10** + +No mature, production-ready TypeScript library exists for "spawn and manage multiple AI CLI agents as child processes." The closest is `pi-builder` from the `awesome-cli-coding-agents` ecosystem, but it's a young project (~100 stars) with no stability guarantees. + +**Verdict: REJECT.** The problem is too niche and CLI-specific for a generic library. Each CLI has its own protocol (Claude stream-json, Codex JSON-RPC, Gemini NDJSON, Goose recipes). A generic library would either be too thin to be useful or too opinionated to handle the differences. + +--- + +#### Level 1 Recommendation: Option A (Own Adapter Pattern) + +| Criteria | Score | +|----------|-------| +| Fit with existing code patterns | 10/10 — mirrors how `spawnCli()` and `ClaudeBinaryResolver` already work | +| Lines of code to integrate | ~800 LOC (interface + 4 adapters + factory) | +| Heavy dependencies added | 0 | +| Runs in Electron main process | Yes (pure Node.js) | +| License compatibility | N/A (our own code, AGPL-3.0) | +| Active maintenance | By us — full control | + +**Migration path**: Extract current Claude-specific logic from `TeamProvisioningService` into `ClaudeCliAdapter`, then add adapters for other CLIs one by one. The monster 7,982 LOC monolith gets decomposed as a side effect. + +--- + +## Level 2: Programmatic LLM API Calls + +### The Candidates + +#### Option A: Vercel AI SDK (`ai` + `@ai-sdk/*`) +**Reliability: 9/10 | Confidence: 9/10** (Recommended) + +The leading TypeScript LLM abstraction. 20M+ monthly npm downloads, backed by Vercel, 30K+ GitHub stars. + +```typescript +// src/main/services/llm/LlmService.ts +import { generateText, streamText, tool } from 'ai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { openai } from '@ai-sdk/openai'; +import { google } from '@ai-sdk/google'; +import { z } from 'zod'; + +// Simple code review — runs in Electron main process +export async function reviewCode(diff: string, model = 'anthropic/claude-sonnet-4-20250514') { + const { text } = await generateText({ + model: anthropic('claude-sonnet-4-20250514'), + system: 'You are a code reviewer. Be concise.', + prompt: `Review this diff:\n\n${diff}`, + }); + return text; +} + +// Streaming task planning with tool calling — relayed to renderer via IPC +export async function planTasks( + description: string, + onChunk: (text: string) => void, +) { + const result = streamText({ + model: openai('gpt-4o'), + system: 'You are a project planner.', + prompt: description, + tools: { + createTask: tool({ + description: 'Create a new task on the kanban board', + parameters: z.object({ + title: z.string(), + assignee: z.string().optional(), + column: z.enum(['backlog', 'todo', 'in_progress']), + }), + execute: async ({ title, assignee, column }) => { + // Call our agent-teams-controller to create task + return controller.createTask({ title, assignee, column }); + }, + }), + }, + maxSteps: 10, // Allow multi-step tool calling loops + }); + + for await (const chunk of result.textStream) { + onChunk(chunk); + } +} + +// Triage incoming issue — pick best team member +export async function triageTask(taskDescription: string) { + const { object } = await generateObject({ + model: google('gemini-2.5-flash'), + schema: z.object({ + assignee: z.string(), + priority: z.enum(['low', 'medium', 'high', 'critical']), + reasoning: z.string(), + }), + prompt: `Triage this task: ${taskDescription}\nAvailable members: alice (frontend), bob (backend), carol (devops)`, + }); + return object; // Typed: { assignee: string; priority: string; reasoning: string } +} +``` + +**What we install:** +```bash +pnpm add ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/google zod +# ai: 67.5 kB gzipped (core) +# @ai-sdk/anthropic: ~15 kB gzipped +# @ai-sdk/openai: ~19.5 kB gzipped +# @ai-sdk/google: ~15 kB gzipped +# Total: ~117 kB gzipped — very reasonable for Electron +``` + +**Pros:** +- Unified `generateText()` / `streamText()` / `generateObject()` API across ALL providers +- Swap provider with one line change: `anthropic('claude-sonnet-4-20250514')` → `openai('gpt-4o')` +- First-class tool calling with Zod schema validation +- Streaming works perfectly in Node.js (Electron main process) +- Sentry already has `vercelAIIntegration` for Electron — we already use `@sentry/electron` +- TypeScript-first: full type inference for tool parameters and structured outputs +- AI SDK 6 `Agent` class for reusable agent patterns +- 20M+ monthly downloads, extremely active maintenance, battle-tested +- Apache-2.0 license — compatible with our AGPL-3.0 + +**Cons:** +- Adds ~4 new deps (ai, 3 providers) — but they're lightweight +- Learning curve for Zod schemas (though Zod is industry standard) +- AI SDK 5→6 had some breaking changes — minor version churn risk + +**Electron main process integration:** +```typescript +// src/main/ipc/llm.ts — IPC handlers for renderer +import { wrapHandler } from './utils'; +import { streamText } from 'ai'; +import { anthropic } from '@ai-sdk/anthropic'; + +export function registerLlmHandlers() { + // One-shot generation + ipcMain.handle('llm:generate', wrapHandler(async (_event, params) => { + const { text } = await generateText({ + model: resolveModel(params.model), // 'anthropic/claude-sonnet-4-20250514' → anthropic('claude-sonnet-4-20250514') + system: params.system, + prompt: params.prompt, + }); + return { text }; + })); + + // Streaming — emit chunks via webContents.send() + ipcMain.handle('llm:stream', wrapHandler(async (event, params) => { + const result = streamText({ + model: resolveModel(params.model), + system: params.system, + prompt: params.prompt, + }); + + const sender = event.sender; + for await (const chunk of result.textStream) { + sender.send('llm:chunk', { requestId: params.requestId, chunk }); + } + sender.send('llm:done', { requestId: params.requestId }); + return { started: true }; + })); +} +``` + +--- + +#### Option B: Mastra (LLM layer only) +**Reliability: 6/10 | Confidence: 5/10** + +Mastra is a full agent framework (workflows, RAG, memory, server). Using "just the LLM layer" means using Mastra's `Agent` class which internally uses AI SDK anyway. + +```typescript +import { Agent } from '@mastra/core/agent'; + +const reviewer = new Agent({ + id: 'code-reviewer', + instructions: 'You are a code reviewer.', + model: 'anthropic/claude-sonnet-4-20250514', +}); + +const result = await reviewer.generate('Review this diff...'); +``` + +**Pros:** +- Nice `Agent` abstraction with built-in memory and workflow support +- Uses AI SDK internally — same providers +- TypeScript-native + +**Cons:** +- `@mastra/core` pulls in significant dependencies (server framework, storage adapters, DI container) +- Overkill for our use case — we need `generateText()`, not the full agent runtime +- Our agent runtime IS the CLI process management layer, not Mastra's in-process loop +- Less mature than AI SDK (smaller community, fewer downloads) +- Adds unnecessary abstraction layer on top of AI SDK +- YC-backed startup — could pivot or die; AI SDK is backed by Vercel ($3.2B company) + +**See also:** `docs/research/mastra-integration-analysis.md` (full analysis, verdict 6/10 feasibility) + +--- + +#### Option C: LangChain.js +**Reliability: 4/10 | Confidence: 3/10** + +```typescript +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatOpenAI } from '@langchain/openai'; + +const chat = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }); +const result = await chat.invoke('Review this diff...'); +``` + +**Pros:** +- Largest ecosystem (chains, agents, RAG, memory) +- Many tutorials and examples + +**Cons:** +- **101 kB gzipped** — 3x the size of OpenAI SDK, 1.5x AI SDK +- Heavy dependency tree (infamous for bloat) +- Frequent breaking changes between versions +- Overcomplicated abstractions for simple LLM calls +- Edge runtime incompatible (uses Node `fs`) +- Community frustration well-documented: "LangChain adds unnecessary complexity" +- For our use case (simple API calls with tool calling), it's a 10-ton truck for a bicycle ride + +--- + +#### Option D: LiteLLM (via proxy) +**Reliability: 5/10 | Confidence: 4/10** + +Run a Python proxy process, point OpenAI SDK at it. + +```typescript +import OpenAI from 'openai'; + +const client = new OpenAI({ + baseURL: 'http://localhost:4000', // LiteLLM proxy + apiKey: 'sk-anything', +}); + +const result = await client.chat.completions.create({ + model: 'anthropic/claude-sonnet-4-20250514', + messages: [{ role: 'user', content: 'Review this diff...' }], +}); +``` + +**Pros:** +- 100+ providers through OpenAI-compatible API +- Rate limiting, fallbacks, cost tracking built-in +- Established in production at many companies + +**Cons:** +- **Requires Python runtime** — catastrophic for an Electron desktop app +- Another long-lived process to manage (proxy lifecycle) +- Performance degrades under concurrency (Python GIL) +- Extra latency hop: Electron → proxy → provider → proxy → Electron +- Enterprise features (SSO, RBAC) behind paid license +- Electron users expect a self-contained app, not "also install Python 3.11" + +--- + +#### Option E: Direct Provider SDKs with Thin Wrapper +**Reliability: 7/10 | Confidence: 7/10** + +```typescript +import Anthropic from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; + +async function callLlm(provider: string, prompt: string) { + switch (provider) { + case 'anthropic': { + const client = new Anthropic(); + const msg = await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }); + return msg.content[0].type === 'text' ? msg.content[0].text : ''; + } + case 'openai': { + const client = new OpenAI(); + const result = await client.chat.completions.create({ + model: 'gpt-4o', + messages: [{ role: 'user', content: prompt }], + }); + return result.choices[0]?.message?.content ?? ''; + } + // ...each provider has different API shape + } +} +``` + +**Pros:** +- Each SDK is lightweight and well-maintained +- No abstraction overhead — direct control + +**Cons:** +- Must implement unified tool calling ourselves (Anthropic tools format ≠ OpenAI function calling ≠ Google tool format) +- Must implement streaming ourselves for each provider +- Must implement structured output extraction per-provider +- Maintenance burden grows linearly with each new provider +- This is literally what AI SDK already does, but worse + +--- + +### Level 2 Recommendation: Option A (Vercel AI SDK) + +| Criteria | Score | +|----------|-------| +| Fit with existing code patterns | 9/10 — pure TypeScript, Node.js-compatible, modular | +| Lines of code to integrate | ~200 LOC (LlmService + IPC handlers) | +| Heavy dependencies added | No — ~117 kB gzipped total for core + 3 providers | +| Runs in Electron main process | Yes — confirmed by Sentry Electron integration docs | +| License compatibility | Apache-2.0 → compatible with our AGPL-3.0 | +| Active maintenance | 10/10 — 20M+ monthly downloads, Vercel-backed | + +--- + +## Combined Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Level 1: CLI Process Management │ │ +│ │ │ │ +│ │ AgentCliAdapter (interface) │ │ +│ │ ├─ ClaudeCliAdapter (stream-json NDJSON) │ │ +│ │ ├─ CodexCliAdapter (app-server JSON-RPC) │ │ +│ │ ├─ GeminiCliAdapter (stream-json NDJSON) │ │ +│ │ └─ GooseCliAdapter (stdin recipes) │ │ +│ │ │ │ +│ │ spawnCli() + killProcessTree() (unchanged) │ │ +│ │ TeamMcpConfigBuilder (unchanged) │ │ +│ │ TeamProvisioningService (refactored to use │ │ +│ │ adapter.parseStdoutLine() etc.) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Level 2: Programmatic LLM API Calls │ │ +│ │ │ │ +│ │ Vercel AI SDK (ai + @ai-sdk/*) │ │ +│ │ ├─ generateText() → code review, triage │ │ +│ │ ├─ streamText() → task planning, chat │ │ +│ │ ├─ generateObject()→ structured extraction │ │ +│ │ └─ tool() → MCP tool bridges │ │ +│ │ │ │ +│ │ LlmService.ts (~200 LOC) │ │ +│ │ IPC handlers → renderer │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Shared: agent-teams-controller │ │ +│ │ (provider-agnostic task/kanban/inbox CRUD) │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Comparison Matrix + +### Level 1: CLI Process Management + +| Criterion | Own Adapter | node-pty | MCO/Third-Party | +|-----------|-------------|----------|-----------------| +| Reliability | 9/10 | 5/10 | 3/10 | +| Confidence | 9/10 | 4/10 | 3/10 | +| Fit with codebase | 10/10 | 4/10 | 3/10 | +| New dependencies | 0 | 0 (already have) | Unknown | +| LOC to integrate | ~800 | ~600 | ~1000+ | +| Electron compatible | Yes | Yes (fragile) | Unknown | +| Handles protocol diffs | Explicit | No (raw PTY) | Generic/lossy | + +### Level 2: Programmatic LLM API Calls + +| Criterion | AI SDK | Mastra | LangChain | LiteLLM | Direct SDKs | +|-----------|--------|--------|-----------|---------|-------------| +| Reliability | 9/10 | 6/10 | 4/10 | 5/10 | 7/10 | +| Confidence | 9/10 | 5/10 | 3/10 | 4/10 | 7/10 | +| Fit with codebase | 9/10 | 5/10 | 3/10 | 2/10 | 7/10 | +| Bundle size | 117 kB | ~400+ kB | 101 kB + deps | N/A (Python) | ~80 kB | +| Tool calling | Unified | Unified (via AI SDK) | Unified | OpenAI-compat | Per-provider | +| Streaming | Async iterator | Async iterator | Chains | SSE proxy | Per-provider | +| Providers | 20+ | 94 (via AI SDK) | 20+ | 100+ | Each separate | +| Electron main proc | Confirmed | Untested | Problematic | Requires Python | Yes | +| License | Apache-2.0 | Elastic-2.0 / AGPL-3.0 | MIT | MIT | Varies | +| Maintenance | Vercel (huge team) | Startup (small) | Community | Community | Per-vendor | + +--- + +## Final Recommendation + +### Level 1: Own Adapter Pattern +- **0 new dependencies**, ~800 LOC +- Extract Claude-specific logic from the 7,982 LOC monolith into `ClaudeCliAdapter` +- Add `CodexCliAdapter`, `GeminiCliAdapter`, `GooseCliAdapter` incrementally +- Each adapter handles that CLI's unique protocol, binary resolution, spawn args, kill semantics +- Decomposes the monolith as a beneficial side effect + +### Level 2: Vercel AI SDK (`ai` + `@ai-sdk/*`) +- **4 lightweight deps** (~117 kB gzipped total), ~200 LOC integration +- `generateText()` for one-shot tasks, `streamText()` for interactive, `generateObject()` for structured extraction +- Unified tool calling with Zod schemas +- Swap any provider with one line change +- Apache-2.0 compatible with our AGPL-3.0 +- Already used by 20M+ monthly projects, confirmed Electron compatibility + +### Implementation Order + +1. **Week 1**: Create `AgentCliAdapter` interface, extract `ClaudeCliAdapter` from `TeamProvisioningService` +2. **Week 1**: Install AI SDK, create `LlmService.ts` with `generateText()` wrapper, add IPC handlers +3. **Week 2**: Add `CodexCliAdapter` (app-server JSON-RPC mode) +4. **Week 2**: Build code review bot using AI SDK + MCP tools +5. **Week 3**: Add `GeminiCliAdapter`, `GooseCliAdapter` +6. **Week 3**: Build triage bot, task planning with `streamText()` + tool calling + +**Total effort**: ~3 weeks for full multi-provider support at both levels. + +--- + +## Sources + +### AI SDK (Vercel) +- [AI SDK Introduction](https://ai-sdk.dev/docs/introduction) +- [AI SDK 6 Announcement](https://vercel.com/blog/ai-sdk-6) +- [Node.js Getting Started](https://ai-sdk.dev/docs/getting-started/nodejs) +- [Providers and Models](https://ai-sdk.dev/docs/foundations/providers-and-models) +- [Sentry Electron + Vercel AI Integration](https://docs.sentry.io/platforms/javascript/guides/electron/configuration/integrations/vercelai/) +- [Generating Text](https://ai-sdk.dev/docs/ai-sdk-core/generating-text) +- [npm: ai](https://www.npmjs.com/package/ai) +- [GitHub: vercel/ai](https://github.com/vercel/ai) + +### Codex CLI +- [Codex SDK](https://developers.openai.com/codex/sdk) +- [Codex App Server](https://developers.openai.com/codex/app-server) +- [npm: @openai/codex-sdk](https://www.npmjs.com/package/@openai/codex-sdk) +- [CLI Reference](https://developers.openai.com/codex/cli/reference) + +### Gemini CLI +- [Headless Mode Reference](https://geminicli.com/docs/cli/headless/) +- [GitHub: google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) + +### Goose +- [GitHub: block/goose](https://github.com/block/goose) +- [CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) + +### Mastra +- [GitHub: mastra-ai/mastra](https://github.com/mastra-ai/mastra) +- [Mastra Docs: Models](https://mastra.ai/models) + +### LangChain.js +- [LangChain vs Vercel AI SDK vs OpenAI SDK: 2026 Guide](https://strapi.io/blog/langchain-vs-vercel-ai-sdk-vs-openai-sdk-comparison-guide) +- [Bundle Size Issue #809](https://github.com/langchain-ai/langchainjs/issues/809) +- [LangChain Criticism](https://community.latenode.com/t/why-im-avoiding-langchain-in-2025/39046) + +### LiteLLM +- [LiteLLM Proxy Docs](https://docs.litellm.ai/docs/simple_proxy) +- [Best LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/best-litellm-alternatives-in-2026/) + +### License Compatibility +- [Apache License and GPL Compatibility](https://www.apache.org/licenses/GPL-compatibility.html) +- [Apache 2.0 Compatible Licenses Guide](https://licensecheck.io/guides/apache-compatible) + +### Ecosystem +- [CLI Coding Agents Comparison 2026](https://www.tembo.io/blog/coding-cli-tools-comparison) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) diff --git a/docs/research/best-integration-approach.md b/docs/research/best-integration-approach.md new file mode 100644 index 00000000..45cbb764 --- /dev/null +++ b/docs/research/best-integration-approach.md @@ -0,0 +1,406 @@ +# Best Integration Approach for Multi-Provider Agent Support + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: deep codebase analysis of actual source files + +--- + +## Executive Summary + +After analyzing 21,584 LOC in `src/main/services/team/`, 2,973 LOC in `src/main/ipc/teams.ts`, 1,245 LOC in `mcp-server/src/`, and all prompt engineering in `TeamProvisioningService.ts` (7,982 LOC), the recommendation is: + +**Option 7: Hybrid approach** — keep Claude Code native support as-is, enhance the existing MCP server to be the universal integration point for other agents. + +This is the only approach that ships incrementally, preserves our working architecture, and provides real multi-provider value within 2-3 weeks. + +--- + +## Architecture Deep Dive + +### Coupling Map (actual file references) + +#### Layer 1: Process Management (9/10 coupling to Claude) +- `src/main/services/team/ClaudeBinaryResolver.ts` (292 LOC) — resolves `claude` binary across PATH, NVM, platform-specific dirs +- `src/main/services/team/TeamProvisioningService.ts` (7,982 LOC) — the monolith: process spawn, stream-json parsing, prompt engineering, inbox relay, tool approval, stall detection, auth retry +- `src/main/utils/childProcess.ts` — `spawnCli()` injects `CLAUDE_HOOK_JUDGE_MODE` env var +- Claude CLI flags hardcoded: `--input-format stream-json`, `--output-format stream-json`, `--verbose`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--dangerously-skip-permissions`, `--permission-prompt-tool`, `--permission-mode`, `--model`, `--effort`, `--worktree`, `--resume` +- Kill semantics: `killTeamProcess()` uses SIGKILL because Claude CLI SIGTERM cleanup **deletes team files** + +#### Layer 2: Protocol (10/10 coupling to Claude) +- stream-json protocol is entirely Claude-proprietary +- `HANDLED_STREAM_JSON_TYPES` = `user`, `assistant`, `control_request`, `result`, `system` +- Input format: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}\n` +- Output parsing: 60+ branches in `handleStreamJsonMessage()` (lines 4858-5294) +- `control_request` for tool approval — Claude Code-specific flow +- Teammate message format: `content` + +#### Layer 3: Prompt Engineering (10/10 coupling to Claude) +- `buildProvisioningPrompt()` (lines 860-953) — tells Claude to use `TeamCreate` built-in tool, then `Task` tool with `team_name` parameter to spawn teammates +- `buildMemberSpawnPrompt()` (lines 444-478) — instructs member to call `member_briefing` MCP tool first, then work with MCP task tools +- `buildPersistentLeadContext()` (lines 664-766) — 100+ line constraint block teaching Claude about kanban, review workflow, delegation-first behavior, agent block policy, cross-team messaging +- `buildTeamCtlOpsInstructions()` (lines 563-662) — exact MCP tool call examples: `task_create`, `task_get`, `kanban_set_column`, `review_approve`, etc. +- `buildActionModeProtocol()` — imports from `agent-teams-controller` via `protocols.buildActionModeProtocolText()` + +**Key insight**: The prompt teaches Claude to use two categories of tools: +1. **Claude Code built-in tools**: `TeamCreate`, `TeamDelete`, `TaskCreate` (the CLI's internal Task tool for spawning subagents), `SendMessage` — these exist ONLY in Claude Code +2. **MCP tools**: `task_create`, `task_get`, `task_list`, `kanban_get`, `review_approve`, `message_send`, etc. — these come from our `agent-teams-mcp` server and are **provider-agnostic** + +#### Layer 4: Data Layer (5/10 coupling — mostly agnostic) +- `agent-teams-controller` (workspace package) — **provider-agnostic** file-based CRUD for tasks, kanban, reviews, messages, processes +- `TeamDataService.ts` (1,953 LOC) — reads team data, invokes controller. Most logic is generic +- `TeamInboxWriter.ts` — writes JSON inbox files. No Claude-specific code +- `TeamTaskReader.ts`, `TeamTaskWriter.ts` — file-based task CRUD via controller +- `TeamKanbanManager.ts` — kanban state management via controller +- `TeamConfigReader.ts` — reads `config.json` from `~/.claude/teams//` +- Path dependency: `~/.claude/teams/` and `~/.claude/tasks/` via `pathDecoder.ts` + +#### Layer 5: MCP Server (0/10 coupling — fully agnostic) +- `mcp-server/src/` (1,245 LOC) — FastMCP server exposing 30+ tools +- **Already exposed tools**: + - Tasks: `task_create`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` + - Kanban: `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` + - Review: `review_request`, `review_start`, `review_approve`, `review_request_changes` + - Messages: `message_send` + - Processes: `process_register`, `process_list`, `process_unregister`, `process_stop` + - Cross-team: `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` + - Runtime: `team_launch`, `team_stop` +- Uses `agent-teams-controller` directly — no Claude Code dependency in MCP tools +- All tools take `teamName` + `claudeDir` as context parameters + +#### Layer 6: HTTP Control API (2/10 coupling) +- `src/main/http/teams.ts` — REST API for `POST /api/teams/:teamName/launch` and `/stop` +- `TeamControlApiState.ts` — publishes control endpoint to `~/.claude/team-control-api.json` +- Thin wrapper over `TeamProvisioningService` — the provisioning itself is Claude-coupled, but the HTTP API shape is generic + +--- + +## Approach Evaluation + +### 1. Mastra (TS-native orchestration framework) + +**Confidence: 4/10 | Reliability: 5/10** + +- **What it is**: Full TS-native agent framework with workflows, tools, memory, RAG +- **Effort**: 8-12 weeks +- **What breaks**: Everything. Mastra has its own agent lifecycle, tool system, and workflow engine. Our entire `TeamProvisioningService` (8K LOC), `TeamDataService` (2K LOC), prompt engineering, stream-json protocol, inbox system, kanban logic would need to be replaced or wrapped +- **What stays**: UI components (renderer), shared types, some utility code +- **Reusable code**: ~20% (UI, types, file watching) +- **Risk**: Very High. Mastra is designed for API-based agents (OpenAI, Anthropic API), not CLI-based agents. Claude Code Agent Teams runs as a CLI process with stream-json — Mastra has no concept of this. Would require either: + - Abandoning Claude Code CLI in favor of raw Anthropic API calls (losing Agent Teams, built-in tools, session persistence) + - Building a massive adapter layer to make Claude Code CLI look like a Mastra "agent" +- **Quality**: Medium. Multi-provider support would be good, but we'd lose all Claude Code-specific features that make the product unique +- **Verdict**: Massive rewrite for uncertain benefit. Our product IS Claude Code Agent Teams UI — Mastra would replace the foundation + +### 2. MCO (dispatch layer) + +**Confidence: 3/10 | Reliability: 4/10** + +- **What it is**: Lightweight dispatch layer for routing tasks to different agent providers +- **Effort**: 6-8 weeks +- **What breaks**: Same fundamental problem as Mastra — MCO dispatches to "agents" but doesn't understand Claude Code's CLI protocol, stream-json, Agent Teams, or our inbox system +- **What stays**: Data layer, UI, some services +- **Reusable code**: ~30% +- **Risk**: High. MCO is minimal and would require us to build most of the integration ourselves anyway +- **Quality**: Low-Medium. MCO is too thin to solve the real problems (protocol translation, process management, prompt adaptation) +- **Verdict**: All the work of a custom solution without the benefit of framework support + +### 3. Overstory Pattern (AgentRuntime interface + SQLite mail) + +**Confidence: 5/10 | Reliability: 6/10** + +- **What it is**: Abstract `AgentRuntime` interface with SQLite-backed message queue +- **Effort**: 6-10 weeks +- **What breaks**: Process management, protocol layer, prompt engineering +- **What stays**: UI, kanban logic, data layer structure (would migrate from JSON files to SQLite) +- **Reusable code**: ~35% +- **Risk**: High. Major architectural change (JSON files -> SQLite, inbox files -> SQLite mail queue). All of `TeamProvisioningService` would need rewriting for each provider +- **Quality**: Good long-term architecture, but: + - We already HAVE a working message system (JSON inbox files + file watchers) + - SQLite migration would break compatibility with Claude Code CLI's native file format + - Claude Code reads/writes `~/.claude/teams//inboxes/.json` directly — switching to SQLite means Claude Code can't participate without a shim +- **Verdict**: Architecturally elegant but fights against Claude Code's native file-based protocol + +### 4. mozilla/any-agent (meta-framework) + +**Confidence: 3/10 | Reliability: 3/10** + +- **What it is**: Python meta-framework to switch agent providers via config +- **Effort**: 10-14 weeks +- **What breaks**: Language barrier — our entire codebase is TypeScript/Electron. any-agent is Python +- **What stays**: UI (renderer) +- **Reusable code**: ~15% (UI only) +- **Risk**: Very High. Would need either: + - Python backend + IPC bridge to Electron renderer (architectural nightmare) + - Port any-agent concepts to TypeScript (then it's really option 5) +- **Quality**: Theoretically good multi-provider support, but wrong language ecosystem +- **Verdict**: Non-starter for a TypeScript/Electron project + +### 5. Our own AgentRuntime abstraction + +**Confidence: 6/10 | Reliability: 7/10** + +- **What it is**: Custom `AgentRuntime` interface inspired by the patterns above, implemented in TypeScript +- **Effort**: 8-12 weeks for full implementation, 4-6 weeks for MVP +- **What breaks**: `TeamProvisioningService` would be refactored into multiple provider-specific implementations +- **What stays**: Data layer (`agent-teams-controller`, TeamDataService, MCP server), UI, kanban, review, cross-team +- **Reusable code**: ~55-60% +- **Risk**: Medium-High. The abstraction must account for fundamentally different agent lifecycles: + - Claude Code: CLI process, stream-json, Agent Teams built-in, teammate spawning via Task tool + - Codex: subprocess, different CLI protocol, no native team tools + - Gemini CLI: yet another protocol + - API-based agents: HTTP calls, no process management at all +- **Quality**: Could be excellent if done right. But the abstraction boundary is extremely hard to get right because Claude Code's Agent Teams is so deeply integrated +- **Key interfaces needed**: + +```typescript +interface AgentRuntime { + name: string; + spawn(config: AgentSpawnConfig): Promise; + sendMessage(process: AgentProcess, message: string): Promise; + parseOutput(line: string): ParsedAgentOutput; + kill(process: AgentProcess): void; + checkAuth(): Promise; + buildPrompt(context: PromptContext): string; +} + +interface AgentProcess { + pid: number; + stdin: Writable; + stdout: Readable; + stderr: Readable; + on(event: 'exit', handler: (code: number) => void): void; +} +``` + +- **The hard part**: `TeamProvisioningService` is 7,982 LOC of deeply intertwined logic. Splitting it into provider-agnostic + provider-specific parts is a multi-week refactoring effort. The `handleStreamJsonMessage()` method alone (lines 4858-5294) handles 15+ message types with side effects throughout +- **Verdict**: Right direction, but expensive and risky as a first step + +### 6. MCP-Based Approach (expose kanban as MCP server for external agents) + +**Confidence: 8/10 | Reliability: 8/10** + +- **What it is**: Enhance our existing MCP server so external agents (Codex, Gemini, any MCP-capable agent) connect TO us and use our kanban, tasks, messages, review system +- **Effort**: 2-3 weeks +- **What breaks**: Nothing. This is additive +- **What stays**: Everything. 100% of existing code remains unchanged +- **Reusable code**: 100% +- **Risk**: Low. We already have a working MCP server with 30+ tools +- **Quality**: Surprisingly good for the effort level. Here's why: + - **MCP is a cross-vendor standard** — Codex, Gemini CLI, Cursor, and many others already support MCP + - **Our MCP server already exposes the full API**: tasks, kanban, review, messages, cross-team, processes + - **External agents don't need our prompts** — they bring their own intelligence. They just need tools to interact with our kanban board + - **The user experience is**: open our app, see the kanban board, agents from different providers create tasks, update statuses, send messages, request reviews — all visible on the same board + +What's missing from the current MCP server for this to work: +1. **Team creation/config via MCP** — currently only `team_launch`/`team_stop` exist as runtime tools; need `team_create_config` MCP tool +2. **Member registration via MCP** — external agents need to register themselves as team members without Claude Code's `TeamCreate` built-in +3. **Agent identification** — MCP tools need a way for agents to identify themselves (which provider, which model) +4. **Task assignment notifications** — when a task is assigned to an external agent, something needs to notify that agent (webhook? polling? SSE?) +5. **Standalone MCP server mode** — currently our MCP server is spawned as a child process by `TeamMcpConfigBuilder`. For external agents, it needs to run standalone (it already can via `agent-teams-mcp` bin) + +- **Verdict**: Best bang for the buck. Low risk, high reuse, ships fast, provider-agnostic by design + +### 7. Hybrid: Native Claude Code + MCP Server for Others (RECOMMENDED) + +**Confidence: 9/10 | Reliability: 9/10** + +- **What it is**: Keep Claude Code Agent Teams as the primary (optimized) path. Enhance MCP server as the universal integration point for all other agents. Eventually, even Claude Code agents could use MCP tools (they already do via `--mcp-config`) +- **Effort**: 3-4 weeks for Phase 1, incremental thereafter +- **What breaks**: Nothing +- **What stays**: Everything +- **Reusable code**: 100% +- **Risk**: Very Low + +#### Why this is the right answer + +1. **We already have 90% of the infrastructure**: + - `mcp-server/` with 30+ tools covering tasks, kanban, review, messages, cross-team, processes + - `agent-teams-controller` as provider-agnostic data layer + - HTTP control API for launch/stop + - File watcher system that detects changes from ANY source (not just Claude Code) + +2. **Claude Code is our strongest path — don't break it**: + - `TeamProvisioningService` (8K LOC) is battle-tested, handles edge cases (auth retry, stall detection, post-compact context recovery, tool approval) + - The prompt engineering works. It took months to tune delegation-first behavior, task board discipline, review workflow, cross-team messaging + - Replacing this with a generic abstraction would lose all these optimizations + +3. **MCP is the industry standard for tool interop**: + - Claude Code already uses our MCP tools via `--mcp-config` + - OpenAI Codex supports MCP (announced 2025) + - Google Gemini supports MCP + - Cursor/Windsurf support MCP + - Any MCP-capable agent can connect today + +4. **The prompt is NOT a blocker for other agents**: + - Our prompts teach Claude Code agents how to use MCP tools (`task_create`, `kanban_set_column`, etc.) + - External agents using MCP don't need our prompts — MCP tool descriptions ARE the prompt + - Each MCP tool already has a `description` field that tells any agent what it does + +5. **Incremental delivery**: + - Phase 1: Publish `agent-teams-mcp` as standalone npm package, add missing tools + - Phase 2: Add UI support for "external member" type, show provider badge + - Phase 3: Add notification/polling mechanism for task assignments + - Phase 4: Optionally abstract `TeamProvisioningService` for a second native provider + +--- + +## Implementation Plan + +### Phase 1: MCP Server Enhancement (Week 1-2) + +**Goal**: Any MCP-capable agent can join an existing team and work on tasks. + +New MCP tools to add to `mcp-server/src/tools/`: + +``` +team_join — register external agent as team member +team_leave — unregister from team +team_list_teams — discover available teams +team_get_config — get team configuration +member_register — register with provider/model metadata +member_heartbeat — keepalive for external agents +task_poll_assigned — poll for newly assigned tasks (for agents without push) +task_claim — claim an unassigned task +``` + +Files to modify: +- `mcp-server/src/tools/index.ts` — register new tool modules +- `mcp-server/src/tools/memberTools.ts` — NEW: member lifecycle tools +- `mcp-server/src/tools/teamDiscoveryTools.ts` — NEW: team discovery +- `mcp-server/package.json` — prepare for standalone npm publish +- `mcp-server/src/agent-teams-controller.d.ts` — extend controller types if needed + +Files unchanged (0 modifications to core): +- `src/main/services/team/TeamProvisioningService.ts` — untouched +- `src/main/services/team/TeamDataService.ts` — untouched +- `src/main/ipc/teams.ts` — untouched +- All prompt engineering — untouched + +### Phase 2: UI Support for External Agents (Week 2-3) + +**Goal**: External agents appear on the kanban board with provider badges. + +- `src/shared/types/team.ts` — add `provider?: string`, `model?: string` to `TeamMember` +- `src/renderer/components/team/` — show provider icon/badge next to member name +- `src/main/services/team/TeamDataService.ts` — recognize external members in data reads +- File watchers already detect changes from any source — no changes needed + +### Phase 3: Notification Mechanism (Week 3-4) + +**Goal**: External agents get notified of task assignments without polling. + +Options (ranked): +1. **SSE endpoint** — `GET /api/teams/:teamName/events` — server-sent events for task changes. Reliability: 8/10, Confidence: 8/10 +2. **Webhook** — configure callback URL per member. Reliability: 7/10, Confidence: 7/10 +3. **Polling** — `task_poll_assigned` MCP tool (already planned in Phase 1). Reliability: 9/10, Confidence: 9/10 + +Recommend: Start with polling (simplest), add SSE later. + +### Phase 4: Optional Native Provider (Week 6+, if demand exists) + +**Goal**: Add a second native CLI provider (e.g., Codex) with process management. + +Only NOW would we extract the `AgentRuntime` abstraction from option 5, but scoped: +- Extract binary resolution from `ClaudeBinaryResolver` into `CliProvider` interface +- Extract process spawn from `TeamProvisioningService.createTeam()`/`launchTeam()` into provider-specific implementations +- Keep `TeamProvisioningService` as `ClaudeProvisioningService` (rename) +- Create `CodexProvisioningService` implementing same interface + +This is the expensive part (6-8 weeks), but by Phase 4 we'll know if there's actual demand. + +--- + +## Comparison Table + +| Criterion | Mastra | MCO | Overstory | any-agent | AgentRuntime | MCP-Only | **Hybrid** | +|---|---|---|---|---|---|---|---| +| Effort (weeks) | 8-12 | 6-8 | 6-10 | 10-14 | 8-12 | 2-3 | **3-4** | +| Code reuse | 20% | 30% | 35% | 15% | 55% | 100% | **100%** | +| Risk | Very High | High | High | Very High | Medium-High | Low | **Very Low** | +| Breaks existing? | Yes | Yes | Yes | Yes | Partially | No | **No** | +| Multi-provider quality | Good | Low-Med | Good | Good | Good | Good | **Good** | +| Incremental? | No | No | No | No | Partially | Yes | **Yes** | +| Ships fast? | No | No | No | No | No | Yes | **Yes** | +| Keeps Claude optimized? | No | No | No | No | Partially | Yes | **Yes** | +| Industry standard? | Custom | Custom | Custom | Python | Custom | MCP | **MCP** | +| Confidence | 4/10 | 3/10 | 5/10 | 3/10 | 6/10 | 8/10 | **9/10** | +| Reliability | 5/10 | 4/10 | 6/10 | 3/10 | 7/10 | 8/10 | **9/10** | + +--- + +## Prompt Engineering Analysis + +### What percentage is Claude-specific vs generic? + +| Prompt Section | Claude-Specific? | LOC | Purpose | +|---|---|---|---| +| `buildProvisioningPrompt()` | **100% Claude** | ~95 | Uses TeamCreate built-in, Task tool for spawning | +| `buildMemberSpawnPrompt()` | **30% Claude** | ~35 | MCP tool calls are generic; `Task tool` spawn is Claude | +| `buildPersistentLeadContext()` | **20% Claude** | ~100 | Constraints are generic; `TeamCreate`/`TeamDelete` refs are Claude | +| `buildTeamCtlOpsInstructions()` | **0% Claude** | ~100 | Pure MCP tool examples — any agent can use these | +| `buildActionModeProtocol()` | **0% Claude** | ~30 | Generic action mode behavior | +| `buildAgentBlockUsagePolicy()` | **50% Claude** | ~30 | Agent block format is Claude-specific; concept is generic | +| `buildReconnectMemberSpawnPrompt()` | **30% Claude** | ~50 | Similar to spawn prompt | + +**Overall**: ~35% of prompt content is Claude-specific (spawning, built-in tools). ~65% is generic task management behavior that any agent needs (use MCP tools, update task status, post comments before completing, notify lead after completion). + +**For MCP-based external agents**: The MCP tool `description` fields already serve as the "prompt". External agents don't need our big prompt — they discover tools via MCP protocol and use tool descriptions. The only thing missing is a "bootstrap briefing" MCP tool that gives a new agent its role, workflow instructions, and team context — and we already have `member_briefing` for this. + +--- + +## Risk Analysis for Recommended Approach (Hybrid) + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| MCP adoption stalls | Low | Medium | MCP is already adopted by Claude, Codex, Gemini, Cursor | +| External agents can't follow task workflow | Medium | Low | `member_briefing` provides onboarding; tool descriptions guide behavior | +| Performance with many external agents | Low | Medium | MCP server is lightweight; file I/O is the bottleneck (same as now) | +| Breaking changes in MCP protocol | Very Low | High | MCP spec is stable (v1.0+), FastMCP library handles protocol | +| External agent quality varies | High | Medium | This is a feature, not a bug — user chooses which agents to use | +| Path coupling (`~/.claude/`) | Low | Low | `claudeDir` parameter already supported in all MCP tools | + +--- + +## Final Recommendation + +**Go with Option 7: Hybrid (Claude Code native + MCP for others).** + +Reasoning: +1. **Zero risk to existing product** — nothing changes for Claude Code users +2. **Fastest time to market** — 3-4 weeks for meaningful multi-provider support +3. **100% code reuse** — no refactoring, no migration, no breaking changes +4. **Industry standard** — MCP is the protocol all major AI tools are converging on +5. **Natural evolution** — Phase 4 (native providers) can happen later if justified by demand +6. **Our MCP server already works** — 30+ tools, battle-tested with Claude Code Agent Teams +7. **Competitive advantage** — no one else has a kanban board + MCP server combination + +The key insight is: **we don't need to abstract our process management layer to support multiple providers**. Instead, we expose our **data layer** (tasks, kanban, reviews, messages) via MCP, and let each agent provider bring their own process management. Our app becomes the **collaboration hub** — the kanban board where all agents converge, regardless of provider. + +--- + +## Appendix: Key Source Files Referenced + +| File | LOC | Role | +|---|---|---| +| `src/main/services/team/TeamProvisioningService.ts` | 7,982 | Process lifecycle, prompt engineering, stream-json protocol | +| `src/main/services/team/TeamDataService.ts` | 1,953 | Data reads, controller integration | +| `src/main/ipc/teams.ts` | 2,973 | IPC handlers for all team operations | +| `src/main/services/team/ClaudeBinaryResolver.ts` | 292 | Claude binary resolution | +| `src/main/services/team/TeamInboxWriter.ts` | 80+ | File-based inbox writes | +| `src/main/services/team/TeamMcpConfigBuilder.ts` | 228 | MCP config generation for Claude | +| `src/main/services/team/CrossTeamService.ts` | 60+ | Cross-team messaging | +| `src/main/services/team/actionModeInstructions.ts` | 51 | Action mode protocol | +| `src/main/http/teams.ts` | 160+ | HTTP control API | +| `src/main/utils/childProcess.ts` | 182 | CLI spawn/kill utilities | +| `mcp-server/src/index.ts` | 24 | MCP server entry | +| `mcp-server/src/controller.ts` | 19 | Controller factory | +| `mcp-server/src/tools/taskTools.ts` | 501 | Task MCP tools | +| `mcp-server/src/tools/kanbanTools.ts` | 82 | Kanban MCP tools | +| `mcp-server/src/tools/reviewTools.ts` | 104 | Review MCP tools | +| `mcp-server/src/tools/messageTools.ts` | 60 | Message MCP tools | +| `mcp-server/src/tools/processTools.ts` | 89 | Process MCP tools | +| `mcp-server/src/tools/crossTeamTools.ts` | 81 | Cross-team MCP tools | +| `mcp-server/src/tools/runtimeTools.ts` | 78 | Runtime MCP tools | +| `src/types/agent-teams-controller.d.ts` | 101 | Controller type definitions | +| `src/shared/types/team.ts` | 100+ | Shared team types | diff --git a/docs/research/claude-coupling-analysis.md b/docs/research/claude-coupling-analysis.md new file mode 100644 index 00000000..da8e4b34 --- /dev/null +++ b/docs/research/claude-coupling-analysis.md @@ -0,0 +1,536 @@ +# Claude Coupling Analysis + +Comprehensive analysis of how tightly the Claude Agent Teams UI codebase is coupled to Claude/Claude Code/Claude Agent Teams. The goal is to understand the effort required to abstract the AI provider layer to support other agents (OpenAI Codex, Gemini CLI, etc.). + +**Date**: 2026-03-24 +**Branch**: `dev` +**Commit**: `08be859` + +--- + +## Summary Table + +| Area | Coupling (1-10) | Effort | Key Blockers | +|---|---|---|---| +| 1. Process Management | **9** | High | Binary name, CLI flags, kill semantics | +| 2. Protocol / Communication | **10** | High | stream-json is Claude-proprietary | +| 3. Message Parsing (JSONL) | **9** | High | Schema is Claude Code's internal format | +| 4. Team Management | **10** | Very High | Agent Teams is a Claude Code feature | +| 5. Session Data / Paths | **9** | Medium | `~/.claude/` hardcoded everywhere | +| 6. Authentication | **8** | Medium | `claude auth status`, GCS binary dist | +| 7. MCP Integration | **5** | Low | MCP is a cross-vendor standard | +| 8. UI Components | **6** | Medium | Branding strings, CLAUDE.md references | +| 9. Types / Interfaces | **8** | High | Types mirror Claude Code JSONL schema | +| 10. Configuration | **7** | Medium | Path constants, env vars, config files | +| **Pricing / Cost** | **7** | Medium | pricing.json is Claude-model-centric | +| **Model Parsing** | **9** | Low | `parseModelString()` only handles `claude-*` | + +**Overall Coupling Score: 8.3 / 10** — Deeply coupled to Claude Code at nearly every layer. + +--- + +## 1. Process Management + +**Coupling: 9/10 | Effort: High** + +### Specific Files +- `src/main/services/team/ClaudeBinaryResolver.ts` — resolves the `claude` binary across platforms +- `src/main/utils/childProcess.ts` — `spawnCli()` / `execCli()` wrappers inject `CLAUDE_HOOK_JUDGE_MODE` env var +- `src/main/services/team/TeamProvisioningService.ts` — spawns `claude` with Claude-specific flags +- `src/main/services/infrastructure/CliInstallerService.ts` — downloads `claude` binary from GCS +- `src/main/services/schedule/ScheduledTaskExecutor.ts` — spawns `claude -p` for scheduled tasks + +### What's Claude-specific +1. **Binary name**: `ClaudeBinaryResolver` searches for `claude` binary across PATH, NVM, platform-specific dirs +2. **CLI flags**: `--input-format stream-json`, `--output-format stream-json`, `--verbose`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--dangerously-skip-permissions`, `--permission-prompt-tool`, `--model`, `--effort`, `--worktree`, `--resume`, `--no-session-persistence`, `--max-turns`, `--permission-mode` +3. **Env var**: `CLAUDE_HOOK_JUDGE_MODE: 'true'` injected into every CLI process +4. **Env var**: `CLAUDE_CONFIG_DIR` set in `buildEnrichedEnv()` +5. **Env var override**: `CLAUDE_CLI_PATH` for custom binary location +6. **Kill semantics**: `killTeamProcess()` uses SIGKILL specifically because Claude CLI cleanup on SIGTERM deletes team files +7. **GCS distribution**: `CliInstallerService` downloads from `https://storage.googleapis.com/claude-code-dist-.../claude-code-releases` +8. **Version command**: `claude --version` expected to output `"X.Y.Z (Claude Code)"` +9. **Install command**: `claude install` for shell integration + +### Abstraction Approach +Create a `CliProvider` interface: +```typescript +interface CliProvider { + name: string; + resolveBinaryPath(): Promise; + buildSpawnArgs(options: SpawnOptions): string[]; + buildEnv(binaryPath: string): NodeJS.ProcessEnv; + parseVersionOutput(stdout: string): string; + getKillSignal(): NodeJS.Signals; + install(): Promise; + checkAuth(): Promise; +} +``` +Each provider (ClaudeCliProvider, CodexCliProvider, GeminiCliProvider) implements this. `ClaudeBinaryResolver` becomes `ClaudeCliProvider.resolveBinaryPath()`. + +--- + +## 2. Protocol / Communication + +**Coupling: 10/10 | Effort: High** + +### Specific Files +- `src/main/services/team/TeamProvisioningService.ts` (lines 126-132, 2742-2980, 4849-5290) — stream-json parser +- `src/renderer/utils/streamJsonParser.ts` — renderer-side stream-json log parsing +- `src/renderer/components/team/CliLogsRichView.tsx` — renders stream-json output +- `src/shared/utils/teammateMessageParser.ts` — parses `` XML format + +### What's Claude-specific +1. **stream-json protocol**: Claude Code's proprietary newline-delimited JSON over stdin/stdout + - Input: `{"type":"user","message":{"role":"user","content":[...]}}\n` + - Output types: `user`, `assistant`, `control_request`, `result`, `system` + - `result.success` = turn complete, `result.error` = failure + - `control_request` for tool approval prompts +2. **Message envelope**: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}` +3. **Teammate message format**: XML tags `content` +4. **Preflight ping**: `claude -p "Output only the single word PONG." --output-format text --model haiku --max-turns 1 --no-session-persistence` +5. **Tool approval**: `control_request` type with `tool_input`, `tool_name`, approval via stdin + +### Abstraction Approach +This is the hardest area. Create a `CliProtocol` interface: +```typescript +interface CliProtocol { + formatInputMessage(text: string): string; + parseOutputLine(line: string): ParsedOutputMessage; + isResultSuccess(msg: ParsedOutputMessage): boolean; + isResultError(msg: ParsedOutputMessage): boolean; + isToolApprovalRequest(msg: ParsedOutputMessage): ToolApprovalRequest | null; + formatToolApprovalResponse(approved: boolean): string; + getProtocolFlags(): string[]; // e.g. ['--input-format', 'stream-json', ...] +} +``` +Each agent's protocol would need a distinct implementation. OpenAI Codex uses a different protocol (REST-based sandbox execution, not stdin/stdout). This would require major architectural changes. + +--- + +## 3. Message Parsing (JSONL) + +**Coupling: 9/10 | Effort: High** + +### Specific Files +- `src/main/types/jsonl.ts` — raw JSONL entry types (Claude Code session file format) +- `src/main/types/messages.ts` — parsed message types and type guards +- `src/main/types/domain.ts` — domain types referencing `~/.claude/projects/` structure +- `src/main/types/chunks.ts` — chunk building from parsed messages +- `src/main/utils/jsonl.ts` — JSONL file parser +- `src/main/constants/messageTags.ts` — ``, ``, `` tags + +### What's Claude-specific +1. **JSONL schema**: Entry types (`user`, `assistant`, `system`, `summary`, `file-history-snapshot`, `queue-operation`) are Claude Code's internal format +2. **Content blocks**: `text`, `thinking`, `tool_use`, `tool_result`, `image` — follows Anthropic Messages API schema +3. **`thinking` + `signature`**: Extended thinking is an Anthropic-specific feature +4. **`isMeta` flag**: Claude Code's internal convention for distinguishing real user messages from tool results +5. **`isSidechain`**: Claude Code's flag for subagent messages +6. **`stop_reason`**: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence` — Anthropic API values +7. **XML tags in content**: ``, ``, ``, `` are Claude Code's internal message wrapping +8. **`` model**: Claude Code's marker for system-generated placeholders +9. **`isCompactSummary`**: Claude Code's context compaction mechanism +10. **Usage metadata**: `cache_read_input_tokens`, `cache_creation_input_tokens` — Anthropic cache API + +### Abstraction Approach +Create a `SessionParser` interface that converts provider-specific session data to a normalized `ParsedMessage`: +```typescript +interface SessionDataProvider { + parseSessionFile(path: string): AsyncIterable; + isRealUserMessage(msg: ParsedMessage): boolean; + isToolCall(block: ContentBlock): boolean; + extractToolResult(msg: ParsedMessage): ToolResult | null; +} +``` +The existing `ParsedMessage` type is actually reasonably generic (it has `toolCalls`, `toolResults`, `content`). The provider-specific part is the parsing FROM the raw format TO `ParsedMessage`. New providers would implement different parsers. + +--- + +## 4. Team Management + +**Coupling: 10/10 | Effort: Very High** + +### Specific Files +- `src/main/services/team/TeamProvisioningService.ts` (~7800 lines) — the monolith +- `agent-teams-controller/` — workspace package for file-level team operations +- `src/main/services/team/*.ts` (~35 files) — team data, inbox, tasks, kanban, review, cross-team +- `src/shared/types/team.ts` — TeamConfig, TeamTask, SendMessageRequest, etc. +- `src/main/ipc/teams.ts` — ~65 IPC handlers for team operations +- `src/shared/utils/leadDetection.ts` — detects team lead by `agentType` values + +### What's Claude-specific +1. **Agent Teams is a Claude Code feature**: `TeamCreate`, `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet`, `SendMessage`, `TeamDelete` are Claude Code CLI tools +2. **Team file structure**: `~/.claude/teams/{teamName}/config.json`, `inboxes/{member}.json`, `kanban-state.json`, `processes.json`, `members.meta.json` +3. **Task file structure**: `~/.claude/tasks/{teamName}/{taskId}.json` +4. **Inbox protocol**: File-based message passing — lead reads stdin, teammates read inbox files +5. **Lead/teammate distinction**: Lead uses stream-json, teammates are independent CLI processes +6. **Tool blocking**: `--disallowedTools TeamDelete,TodoWrite` +7. **`agentType` values**: `team-lead`, `lead`, `orchestrator`, `general-purpose` — Claude Code internal values +8. **`teammate_spawned` tool results**: How team member processes are detected +9. **Cross-team communication**: `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` +10. **Action mode instructions**: Custom protocol text injected into team prompts +11. **`agent-teams-controller` package**: Pure JS module that reads Claude Code's team filesystem directly + +### Abstraction Approach +This is by far the hardest area. Agent Teams is a unique Claude Code feature with no equivalent in other CLI agents. Options: +- **Option A**: Keep team management as Claude-only feature, abstract only session viewing +- **Option B**: Build a generic team orchestration layer that wraps different agent CLIs. Would need to implement inbox/task/kanban semantics independently of Claude Code. +- **Option C**: Make team management pluggable — each provider declares `supportsTeams: boolean` and provides a `TeamOrchestrator` implementation if supported + +Option A is the most realistic short-term approach. + +--- + +## 5. Session Data / Paths + +**Coupling: 9/10 | Effort: Medium** + +### Specific Files +- `src/main/utils/pathDecoder.ts` — all path construction (`~/.claude/projects/`, `~/.claude/todos/`, `~/.claude/teams/`, `~/.claude/tasks/`) +- `src/main/services/discovery/ProjectScanner.ts` — scans `~/.claude/projects/` +- `src/main/services/infrastructure/FileWatcher.ts` — watches `~/.claude/projects/`, `~/.claude/todos/`, `~/.claude/teams/`, `~/.claude/tasks/` +- `src/main/services/infrastructure/SshConnectionManager.ts` — hardcodes `~/.claude/projects` for remote +- `src/main/services/infrastructure/ConfigManager.ts` — config at `~/.claude/claude-devtools-config.json` +- `src/main/constants/worktreePatterns.ts` — detects `.claude/worktrees/` pattern + +### What's Claude-specific +1. **Base path**: `~/.claude/` as root for all data +2. **Path encoding**: `/Users/name/project` → `-Users-name-project` (Claude Code's convention) +3. **Session files**: `~/.claude/projects/{encoded-path}/{uuid}.jsonl` +4. **Subagent files**: `~/.claude/projects/{path}/{session_uuid}/agent_{uuid}.jsonl` +5. **Todo files**: `~/.claude/todos/{sessionId}.json` +6. **Team files**: `~/.claude/teams/{teamName}/...` +7. **Task files**: `~/.claude/tasks/{teamName}/{taskId}.json` +8. **Config**: `~/.claude/claude-devtools-config.json` (our config, stored in Claude's directory) +9. **SSH remote**: Hardcoded `/home/{user}/.claude/projects`, `/Users/{user}/.claude/projects`, `/root/.claude/projects` +10. **Worktree patterns**: `.claude/worktrees/` as a known source + +### Abstraction Approach +Path resolution is already partially abstracted via `getClaudeBasePath()` with override support (`setClaudeBasePathOverride`). Extend to: +```typescript +interface DataPathProvider { + getBasePath(): string; // ~/.claude/, ~/.codex/, etc. + getProjectsPath(): string; // {base}/projects/ + getSessionPath(projectId: string, sessionId: string): string; + getSubagentPath(projectId: string, sessionId: string): string; + encodeProjectPath(absolutePath: string): string; + decodeProjectPath(encoded: string): string; +} +``` +Medium effort because path functions are centralized in `pathDecoder.ts`. The SSH remote paths would need provider-specific resolution. + +--- + +## 6. Authentication + +**Coupling: 8/10 | Effort: Medium** + +### Specific Files +- `src/main/services/infrastructure/CliInstallerService.ts` — `claude auth status --output-format json`, `claude --version` +- `src/shared/types/cliInstaller.ts` — `CliInstallationStatus.authLoggedIn`, `authMethod` +- `src/main/utils/cliAuthDiagLog.ts` — diagnostic logging for auth issues +- `src/renderer/components/dashboard/CliStatusBanner.tsx` — shows login status + +### What's Claude-specific +1. **Auth check**: `claude auth status --output-format json` — returns `{loggedIn: boolean, authMethod: string}` +2. **Auth method types**: `"oauth_token"`, `"api_key"` — Claude-specific +3. **Binary distribution**: GCS bucket `claude-code-dist-*` with platform-specific binaries +4. **Install flow**: Downloads binary → SHA256 verify → `claude install` for shell integration +5. **Version parsing**: `"2.1.59 (Claude Code)"` format +6. **Preflight auth check**: Runs `claude -p "PONG"` to verify auth works + +### Abstraction Approach +```typescript +interface CliInstallerProvider { + getLatestVersion(): Promise; + downloadBinary(platform: CliPlatform): Promise; // returns temp path + installBinary(binaryPath: string): Promise; + checkVersion(binaryPath: string): Promise; + checkAuth(binaryPath: string): Promise; +} +``` + +--- + +## 7. MCP Integration + +**Coupling: 5/10 | Effort: Low** + +### Specific Files +- `src/main/services/team/TeamMcpConfigBuilder.ts` — builds MCP config JSON for team processes +- `src/main/services/extensions/install/McpInstallService.ts` — installs MCP servers +- `src/shared/types/extensions/mcp.ts` — MCP types +- `mcp-server/` — built-in MCP server for the app + +### What's Claude-specific +1. **Config file location**: `.claude.json` in home dir, `.mcp.json` in project +2. **CLI flag**: `--mcp-config` to pass config path to CLI +3. **Config format**: Standard MCP format (`{mcpServers: {name: {command, args}}}`) +4. **Built-in server**: `mcp-server/` is our own — not Claude-specific + +### What's NOT Claude-specific +MCP (Model Context Protocol) is becoming a cross-vendor standard. The protocol itself is vendor-neutral. The config format may vary by agent but the server implementation is portable. + +### Abstraction Approach +MCP is already the most abstracted area. The only coupling is the config file naming (`.claude.json`) and the `--mcp-config` flag. A provider interface would specify how to pass MCP config to the CLI. + +--- + +## 8. UI Components + +**Coupling: 6/10 | Effort: Medium** + +### Specific Files +- `src/renderer/index.html` — title "Claude Agent Teams UI" +- `src/renderer/components/common/ErrorBoundary.tsx` — CSS classes `bg-claude-dark-bg`, `text-claude-dark-text` +- `src/renderer/components/team/ClaudeLogsDialog.tsx`, `ClaudeLogsPanel.tsx`, `ClaudeLogsSection.tsx`, `ClaudeLogsFilterPopover.tsx`, `useClaudeLogsController.ts` — "Claude Logs" feature naming +- `src/renderer/types/claudeMd.ts` — CLAUDE.md tracking types +- `src/renderer/utils/claudeMdTracker.ts` (70 occurrences) — CLAUDE.md context tracking +- `src/renderer/utils/contextTracker.ts` (56 occurrences) — references CLAUDE.md sources +- `src/renderer/components/chat/SessionContextPanel/` — CLAUDE.md section +- `src/renderer/components/settings/sections/GeneralSection.tsx` (69 occurrences) — "Claude Root" settings +- `src/renderer/components/dashboard/CliStatusBanner.tsx` — "Claude CLI" status +- `src/renderer/index.css` — comments mentioning "Claude Code" +- `src/shared/constants/cli.ts` — `CLI_NOT_FOUND_MESSAGE = 'Claude CLI not found...'` + +### What's Claude-specific +1. **Branding strings**: "Claude Agent Teams UI", "Claude CLI", "Claude Logs", "Claude Root" +2. **CSS theme variables**: `claude-dark-bg`, `claude-dark-text`, `claude-dark-border`, `claude-dark-surface` in ErrorBoundary +3. **CLAUDE.md feature**: The entire CLAUDE.md tracking system (types, tracker, UI) is Claude Code specific +4. **"Claude Logs"**: 5+ components for viewing CLI logs named "ClaudeLogs*" +5. **Settings**: "Local Claude Root" setting for `~/.claude` override + +### What's NOT Claude-specific +- Chat rendering (UserChunk, AIChunk, SystemChunk) is generic +- Kanban board UI is generic +- Team member list, task management UI is generic +- Tool call visualization is generic (tool_use/tool_result pattern is shared across LLM providers) + +### Abstraction Approach +1. Replace hardcoded strings with a config/branding module +2. Rename `ClaudeLogs*` → `CliLogs*` or `AgentLogs*` +3. Rename `claudeMdTracker` → `instructionFileTracker` (provider specifies filename pattern) +4. CSS variable renaming is mechanical (`claude-dark-*` → `app-dark-*`) +5. "Claude Root" → "Agent Data Directory" + +--- + +## 9. Types / Interfaces + +**Coupling: 8/10 | Effort: High** + +### Specific Files +- `src/main/types/jsonl.ts` — `ChatHistoryEntry` union follows Claude Code JSONL exactly +- `src/main/types/messages.ts` — `ParsedMessage` with Claude-specific fields (`isMeta`, `isSidechain`, `isCompactSummary`) +- `src/main/types/domain.ts` — `MessageType`, `TokenUsage` with `cache_read_input_tokens` +- `src/shared/types/team.ts` — Team types entirely Claude Agent Teams specific +- `src/shared/types/api.ts` — API surface exposes Claude-specific session/team types +- `src/shared/utils/modelParser.ts` — parses `claude-*` model strings only +- `src/shared/utils/pricing.ts` — pricing data is Claude/Anthropic model centric + +### What's Claude-specific +1. **Content block types**: `thinking` with `signature` field — Anthropic extended thinking +2. **Token usage fields**: `cache_read_input_tokens`, `cache_creation_input_tokens` — Anthropic prompt caching +3. **Model string format**: `claude-{family}-{major}-{minor}-{date}` and old `claude-{major}-{family}-{date}` +4. **Model families**: `sonnet`, `opus`, `haiku` — Anthropic model names +5. **`isMeta`/`isSidechain`**: Claude Code's internal conventions +6. **`stop_reason` values**: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence` +7. **Pricing data**: `resources/pricing.json` is Anthropic-model-only (includes Bedrock/Vertex variants) + +### Abstraction Approach +The `ParsedMessage` type is actually fairly close to a generic representation. Key changes: +- Make `thinking` content optional/provider-specific +- Generalize token usage (some fields are Anthropic-specific) +- `modelParser.ts` needs a provider-aware implementation +- Pricing needs multi-provider support (or provider-supplied pricing) + +--- + +## 10. Configuration + +**Coupling: 7/10 | Effort: Medium** + +### Specific Files +- `src/main/services/infrastructure/ConfigManager.ts` — stores config in `~/.claude/claude-devtools-config.json` +- `src/main/utils/cliEnv.ts` — sets `CLAUDE_CONFIG_DIR` env var +- `src/main/utils/pathDecoder.ts` — `getClaudeBasePath()` with override support +- `src/shared/utils/cliArgsParser.ts` — `PROTECTED_CLI_FLAGS` are Claude CLI flags +- `src/main/ipc/config.ts` — configuration IPC handlers +- `src/main/services/team/TeamMcpConfigBuilder.ts` — `.claude.json` user MCP config + +### What's Claude-specific +1. **Config dir**: `~/.claude/` as base +2. **Config filename**: `claude-devtools-config.json` +3. **Env vars**: `CLAUDE_CONFIG_DIR`, `CLAUDE_CLI_PATH`, `CLAUDE_HOOK_JUDGE_MODE` +4. **Protected flags**: `--input-format`, `--output-format`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--verbose` +5. **Settings sources**: `user,project,local` — Claude CLI setting hierarchy +6. **User config files**: `.claude.json` (MCP), `~/.claude/settings.json` + +### Abstraction Approach +Already partially abstracted (`setClaudeBasePathOverride` exists). Extend: +```typescript +interface ProviderConfig { + basePath: string; + configFileName: string; + envVars: Record; + protectedFlags: Set; + settingSources?: string; +} +``` + +--- + +## Additional Coupling: `agent-teams-controller` Package + +**Coupling: 10/10 | Effort: High** + +The `agent-teams-controller/` workspace package is a pure JS module that directly reads/writes Claude Code's team filesystem: +- `runtimeHelpers.js`: `getPaths()` returns `~/.claude/teams/{name}/`, `~/.claude/tasks/{name}/` +- `context.js`: `createControllerContext({teamName, claudeDir})` +- `tasks.js`, `kanban.js`, `review.js`, `messages.js`, etc. — all operate on Claude's file structures + +This package would need to be either: +- Made provider-aware (different file layouts per provider) +- Replaced with a generic team data layer + +--- + +## Estimated Overall Effort for Full Abstraction + +| Phase | Scope | Estimated Effort | +|---|---|---| +| **Phase 1**: Session viewing only | Path abstraction + JSONL parser + model parser | 2-3 weeks | +| **Phase 2**: UI de-branding | Rename strings, CSS vars, component names | 1 week | +| **Phase 3**: CLI provider interface | Binary resolution + auth + install | 2 weeks | +| **Phase 4**: Protocol abstraction | stream-json → generic protocol layer | 3-4 weeks | +| **Phase 5**: Team management abstraction | Generic orchestration layer | 4-8 weeks | +| **Total** | Full multi-provider support | 12-18 weeks | + +--- + +## Recommended Abstraction Strategy + +### Priority Order (what to do first) + +1. **Paths first** (low risk, high reward) — `pathDecoder.ts` already has override support. Make `getBasePath()` provider-aware. This unblocks session viewing for other agents. + +2. **Session parser second** — Create `SessionDataProvider` interface. The existing `ParsedMessage` type works as the normalized target. Each provider implements a parser FROM their raw format TO `ParsedMessage`. + +3. **Model/pricing third** — Make `parseModelString()` and pricing lookup provider-aware. Use a registry pattern where each provider registers its models. + +4. **CLI provider fourth** — Abstract binary resolution, auth, install, spawning. This is where protocol differences become critical. + +5. **Team management last** — This is the hardest and most Claude-specific feature. Consider keeping it as a Claude-only feature initially. + +### What's Hardest + +1. **stream-json protocol** — This is Claude Code's proprietary stdin/stdout protocol. Other agents use completely different paradigms (OpenAI Codex uses sandboxed REST API, Gemini CLI may use different protocol). Abstracting this requires a fundamental architectural decision about how the app communicates with agents. + +2. **Agent Teams** — No other CLI agent has an equivalent feature. The entire team management subsystem (~35 service files, ~65 IPC handlers, controller package) is built around Claude Code's Agent Teams. Supporting multi-agent orchestration for other providers would essentially mean building this from scratch. + +3. **JSONL session format** — Claude Code's JSONL format is deeply embedded in the codebase (types, parsers, chunk builders, context trackers). While `ParsedMessage` serves as a reasonable intermediary, the raw parsing layer touching 10+ files would need provider-specific implementations. + +### What's Easiest + +1. **MCP** — Already vendor-neutral. Only config file naming and CLI flag need adjustment. +2. **UI branding** — Mechanical string/CSS replacement. +3. **Path configuration** — Override mechanism already exists. + +--- + +## Architecture Diagram: Provider-Agnostic Layer + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Renderer (UI) │ +│ Components (generic) │ Store (generic) │ Types (generic)│ +└────────────────────────────┬────────────────────────────────┘ + │ IPC +┌────────────────────────────┴────────────────────────────────┐ +│ Provider Manager │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Session │ │ CLI │ │ Team │ │ +│ │ Provider │ │ Provider │ │ Provider │ ← Interfaces │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ +│ ┌─────┴────┐ ┌─────┴────┐ ┌─────┴────────┐ │ +│ │ Claude │ │ Claude │ │ Claude Agent │ │ +│ │ JSONL │ │ CLI │ │ Teams │ ← Impls │ +│ │ Parser │ │ Spawner │ │ Orchestrator │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ Codex │ │ Codex │ │ (not │ │ +│ │ Session │ │ CLI │ │ supported) │ ← Future │ +│ │ Parser │ │ Spawner │ │ │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────┴────────────────────────────────┐ +│ Data Path Provider │ +│ ~/.claude/ │ ~/.codex/ │ ~/.gemini/ │ etc. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Interfaces + +``` +CliProvider +├── resolveBinaryPath() → string | null +├── buildSpawnArgs(opts) → string[] +├── buildEnv(binary) → ProcessEnv +├── checkAuth(binary) → AuthStatus +├── getKillSignal() → Signals +└── getProtocolFlags() → string[] + +CliProtocol +├── formatInputMessage(text) → string +├── parseOutputLine(line) → ParsedOutput +├── isSuccess(msg) → boolean +├── isError(msg) → boolean +└── isToolApproval(msg) → ToolApproval | null + +SessionDataProvider +├── parseSessionFile(path) → AsyncIterable +├── getSessionPaths(basePath) → string[] +├── getSubagentPaths(sessionPath) → string[] +└── encodeProjectPath(path) → string + +DataPathProvider +├── getBasePath() → string +├── getProjectsPath() → string +├── getTeamsPath() → string | null +├── getSessionFilePath(project, session) → string +└── getConfigFilePath() → string + +TeamOrchestrator (optional per provider) +├── supportsTeams: boolean +├── createTeam(request) → TeamCreateResponse +├── launchTeam(request) → TeamLaunchResponse +├── sendMessage(team, request) → SendMessageResult +└── stopTeam(teamName) → void + +ModelInfoProvider +├── parseModelString(model) → ModelInfo | null +├── getModelFamilies() → string[] +├── getPricing(model) → Pricing | null +└── getContextWindow(model) → number + +InstructionFileProvider +├── getFilename() → string // "CLAUDE.md", ".codexrc", etc. +├── getGlobalPath() → string +├── getProjectPath(projectDir) → string +└── getSourceTypes() → string[] +``` + +--- + +## Conclusion + +The codebase is deeply coupled to Claude Code at approximately 8.3/10 overall. The coupling is most severe in: +1. **Team management** (10/10) — Claude Agent Teams is a unique feature with no equivalent +2. **Protocol** (10/10) — stream-json is proprietary +3. **Session data** (9/10) — JSONL format, path encoding, file structure +4. **Process management** (9/10) — Claude binary, flags, kill semantics + +The most pragmatic path to multi-provider support would be a phased approach starting with session viewing (paths + JSONL parser abstraction), which delivers value with ~3 weeks effort, before tackling the much harder protocol and team management layers. + +Full abstraction to support other agents with team management would require 12-18 weeks of focused effort, with the protocol and team management layers being the primary engineering challenges. diff --git a/docs/research/claude-kanban-dataflow.md b/docs/research/claude-kanban-dataflow.md new file mode 100644 index 00000000..fe696db9 --- /dev/null +++ b/docs/research/claude-kanban-dataflow.md @@ -0,0 +1,431 @@ +# Claude Kanban Data Flow: Full Architecture Analysis + +## Executive Summary + +Claude Code **does NOT use its own built-in Agent Teams tools** (TaskCreate, TaskUpdate, TaskList, etc.) for kanban management. Instead, our app injects a **custom MCP server** (`agent-teams-mcp`) that provides its own set of tools (`task_create`, `task_list`, `task_start`, `task_complete`, `review_request`, etc.). Claude's built-in `TaskCreate` is explicitly demoted to "optional for private planning only" via the provisioning prompt. + +The data flow is: **Claude calls MCP tools → agent-teams-controller writes JSON files to disk → fs.watch() detects changes → IPC event → React UI updates**. + +--- + +## 1. How the MCP Server Gets Injected + +### TeamMcpConfigBuilder (`src/main/services/team/TeamMcpConfigBuilder.ts`) + +When a team is created or launched, `TeamMcpConfigBuilder.writeConfigFile()` generates a temporary JSON file: + +``` +/tmp/claude-team-mcp/agent-teams-mcp-.json +``` + +Contents: +```json +{ + "mcpServers": { + "agent-teams": { + "command": "node", + "args": ["/path/to/mcp-server/index.js"] + }, + ...userMcpServers + } +} +``` + +This merges the user's `~/.claude.json` MCP servers with the injected `agent-teams` server (our server wins on name collision). + +### CLI Launch Args (`TeamProvisioningService.ts`, lines 2986-2989) + +```typescript +'--mcp-config', mcpConfigPath, +'--disallowedTools', 'TeamDelete,TodoWrite', +``` + +- `--mcp-config` points Claude CLI to our generated config +- `TeamDelete` is blocked to prevent team cleanup +- `TodoWrite` is blocked because Opus tends to use it instead of our MCP tools +- Claude's native `TaskCreate`/`TaskUpdate` are NOT blocked — they are left available but deprioritized via prompt engineering + +### The Provisioning Prompt (line 724) + +``` +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +``` + +The prompt then explicitly instructs Claude to use MCP tools: + +``` +Task board operations — use MCP tools directly: +- Get task details: task_get { teamName: "...", taskId: "" } +- Create task: task_create { teamName: "...", subject: "...", ... } +- Start task: task_start { teamName: "...", taskId: "" } +... +``` + +--- + +## 2. What MCP Tools Exist + +### MCP Server Structure (`mcp-server/`) + +``` +mcp-server/ +├── src/ +│ ├── index.ts — FastMCP server, stdio transport +│ ├── controller.ts — wraps agent-teams-controller +│ └── tools/ +│ ├── taskTools.ts — task_create, task_list, task_get, task_set_status, task_start, +│ │ task_complete, task_set_owner, task_add_comment, task_link, etc. +│ ├── kanbanTools.ts — kanban_get, kanban_set_column, kanban_clear, kanban_add_reviewer +│ ├── reviewTools.ts — review_request, review_start, review_approve, review_request_changes +│ ├── messageTools.ts +│ ├── processTools.ts +│ ├── runtimeTools.ts +│ └── crossTeamTools.ts +``` + +### Full MCP Tool List + +| Domain | Tools | +|--------|-------| +| Task | `task_create`, `task_create_from_message`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` | +| Kanban | `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` | +| Review | `review_request`, `review_start`, `review_approve`, `review_request_changes` | +| Message | (message-related tools) | +| Process | (process-related tools) | +| Runtime | (runtime-related tools) | +| Cross-team | `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` | + +--- + +## 3. Data Flow: Claude MCP Tool Call → Disk + +### The Shared Library: `agent-teams-controller` + +Both the MCP server and the Electron main process use the same `agent-teams-controller` package (workspace dependency). This is a plain JS library that provides: + +```javascript +// agent-teams-controller/src/controller.js +function createController(options) { + const context = createControllerContext(options); // { teamName, paths } + return { + tasks: bindModule(context, tasks), + kanban: bindModule(context, kanban), + review: bindModule(context, review), + messages: bindModule(context, messages), + ... + }; +} +``` + +### Path Resolution + +```javascript +// agent-teams-controller/src/internal/runtimeHelpers.js +function getPaths(flags, teamName) { + const claudeDir = getClaudeDir(flags); // ~/.claude + return { + teamDir: path.join(claudeDir, 'teams', teamName), + tasksDir: path.join(claudeDir, 'tasks', teamName), + kanbanPath: path.join(claudeDir, 'teams', teamName, 'kanban-state.json'), + ... + }; +} +``` + +So tasks live in `~/.claude/tasks//.json` and kanban state lives in `~/.claude/teams//kanban-state.json`. + +### Task Creation Flow (MCP → Disk) + +1. Claude calls MCP tool: `task_create { teamName: "my-team", subject: "Fix bug" }` +2. `mcp-server/src/tools/taskTools.ts` → `getController(teamName).tasks.createTask(...)` +3. `agent-teams-controller/src/internal/tasks.js` → `taskStore.createTask(context, params)` +4. `agent-teams-controller/src/internal/taskStore.js`: + ```javascript + function writeJson(filePath, value) { + ensureDir(path.dirname(filePath)); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); + fs.renameSync(tempPath, filePath); // atomic write + } + ``` +5. Result: `~/.claude/tasks/my-team/.json` is created + +### Kanban State Flow + +The kanban state is a separate JSON file (`kanban-state.json`) in the teams directory. When Claude calls `review_request` or `kanban_set_column`, the controller writes to `~/.claude/teams//kanban-state.json`. + +--- + +## 4. Data Flow: Disk → UI + +### FileWatcher (`src/main/services/infrastructure/FileWatcher.ts`) + +There are **two separate fs.watch()** watchers: + +1. **Teams watcher** — watches `~/.claude/teams/` (recursive) + - Detects: `config.json`, `kanban-state.json`, `inboxes/*.json`, `sentMessages.json`, `processes.json` + +2. **Tasks watcher** — watches `~/.claude/tasks/` (recursive) + - Detects: `/.json` changes + +When a file changes: + +```typescript +// FileWatcher.ts, line 404 +this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => { + this.handleTasksChange(eventType, filename); +}); +``` + +`processTasksChange()` (line 1028) parses the filename to extract `teamName` and `detail` (e.g., "12.json"), then emits: + +```typescript +const event: TeamChangeEvent = { type: 'task', teamName, detail: relative }; +this.emit('team-change', event); +``` + +### Event Propagation (`src/main/index.ts`, line 500-608) + +`wireFileWatcherEvents()` listens for `team-change` events: + +```typescript +context.fileWatcher.on('team-change', teamChangeHandler); +``` + +For task events (`row.type === 'task'`): + +1. **Sends IPC to renderer**: `mainWindow.webContents.send(TEAM_CHANGE, event)` (line 502) +2. **Broadcasts to HTTP SSE**: `httpServer?.broadcast('team-change', event)` (line 504) +3. **Reconciles artifacts**: `teamDataService.reconcileTeamArtifacts(teamName)` (line 583) +4. **Notifies lead**: `teamDataService.notifyLeadOnTeammateTaskStart(teamName, taskId)` (line 590) +5. **Backs up task**: `teamBackupService.scheduleTaskBackup(teamName, detail)` (line 606) + +### UI Data Reading + +The renderer (React) receives `TEAM_CHANGE` events and re-fetches task data via IPC: + +- `team:getTasks` → calls `TeamTaskReader.getTasks(teamName)` which reads all `~/.claude/tasks//*.json` files +- `team:updateKanban` → calls `TeamKanbanManager.updateTask()` which reads/writes `kanban-state.json` + +The Electron `TeamTaskReader` (`src/main/services/team/TeamTaskReader.ts`) re-reads all task JSON files from disk, parses them, filters out `_internal` tasks, normalizes fields, and returns `TeamTask[]` to the renderer. + +--- + +## 5. Claude's Built-in Tools vs Our MCP Tools + +### Claude's Native Built-in Tools (Agent Teams Protocol) + +| Native Tool | Purpose | Blocked? | +|-------------|---------|----------| +| `TeamCreate` | Create team structure (config.json, state) | No — used during provisioning | +| `TaskCreate` | Create a task via CLI internal mechanism | No — but deprioritized by prompt ("optional for private planning only") | +| `TaskUpdate` | Update task via CLI internal mechanism | No — but never instructed to use | +| `TaskList` | List tasks via CLI | No — but never instructed to use | +| `TaskGet` | Get task via CLI | No — but never instructed to use | +| `SendMessage` | Send message between agents | No — actively used for inter-agent chat | +| `TeamDelete` | Delete team | **YES — blocked via --disallowedTools** | +| `TodoWrite` | Write todo items | **YES — blocked via --disallowedTools** | +| `Agent` | Spawn subagent/teammate | No — actively used to spawn teammates | + +### Our MCP Tools (agent-teams-mcp) + +| MCP Tool | Purpose | Claude instructed to use? | +|----------|---------|-------------------------| +| `task_create` | Create task on board | **YES** — primary task creation | +| `task_start` | Move task to in_progress | **YES** | +| `task_complete` | Move task to completed | **YES** | +| `task_add_comment` | Add comment to task | **YES** | +| `task_get` | Read task details | **YES** | +| `task_list` | List all tasks | **YES** | +| `review_request` | Move to review column | **YES** | +| `review_approve` | Approve review | **YES** | +| `kanban_set_column` | Move task on kanban | **YES** | + +### Why This Split? + +Claude's native `TaskCreate` writes tasks to `~/.claude/tasks//.json` too — the same location. But: + +1. **Our MCP tools add richer fields** (displayId, workIntervals, historyEvents, comments, attachments, reviewState, sourceMessage, etc.) +2. **Our MCP tools enforce board discipline** (via agent-teams-controller logic) +3. **Our kanban state is a separate file** (`kanban-state.json`) that Claude's native tools don't manage +4. **Review workflow** (review_request → review_start → review_approve / review_request_changes) is entirely our MCP layer + +Claude's native TaskCreate creates simpler task JSON files. The CLI's internal Zod schema requires `description`, `blocks`, `blockedBy` fields — our `TeamTaskWriter.createTask()` (line 68-71) ensures CLI compatibility: +```typescript +const cliCompatibleTask = { + ...task, + description: task.description ?? '', + blocks: task.blocks ?? [], + blockedBy: task.blockedBy ?? [], +}; +``` + +--- + +## 6. The Two-Writer Problem + +Both writers hit the same filesystem: + +| Writer | Writes to | When | +|--------|-----------|------| +| MCP server (agent-teams-controller) | `~/.claude/tasks//.json` | Claude calls `task_create`, `task_set_status`, etc. | +| Electron main (TeamTaskWriter) | `~/.claude/tasks//.json` | UI creates/updates tasks (user clicks "Create Task", drag-drop, etc.) | +| Claude CLI built-in | `~/.claude/tasks//.json` | If Claude uses native TaskCreate (deprioritized) | + +All three write to the same files. Concurrent writes are handled by: +- MCP: `taskStore.writeJson()` uses atomic temp+rename +- Electron: `TeamTaskWriter` uses per-file locks + `atomicWriteAsync()` +- CLI: Its own write mechanism + +There is NO cross-process lock between MCP and Electron — they rely on atomic writes and eventual consistency (file watcher detects changes within ~100ms debounce). + +--- + +## 7. Full Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claude Code CLI (stream-json process) │ +│ │ +│ ┌─────────────────┐ ┌──────────────────────┐ │ +│ │ Built-in Tools │ │ MCP Tools │ │ +│ │ • SendMessage │ │ (agent-teams-mcp) │ │ +│ │ • Agent │ │ • task_create │ │ +│ │ • TaskCreate(*) │ │ • task_start │ │ +│ │ • Read/Write/Bash │ │ • task_complete │ │ +│ └────────┬──────────┘ │ • task_add_comment │ │ +│ │ │ • review_request │ │ +│ │ │ • kanban_set_column │ │ +│ │ └───────────┬──────────────┘ │ +│ │ │ │ +│ stdout (stream-json) agent-teams-controller │ +│ │ │ │ +└───────────┼───────────────────────────┼─────────────────────────────┘ + │ │ + │ ┌──────▼──────────────────┐ + │ │ File System (disk) │ + │ │ │ + │ │ ~/.claude/tasks// │ + │ │ ├── 1.json │ + │ │ ├── 2.json │ + │ │ └── ... │ + │ │ │ + │ │ ~/.claude/teams// │ + │ │ ├── config.json │ + │ │ ├── kanban-state.json │ + │ │ └── inboxes/ │ + │ └──────┬──────────────────┘ + │ │ + │ fs.watch() (recursive) + │ │ +┌───────────┼───────────────────────────┼─────────────────────────────┐ +│ Electron Main Process │ │ +│ │ │ │ +│ ┌────────▼──────────┐ ┌───────────▼───────────┐ │ +│ │ TeamProvisioning │ │ FileWatcher │ │ +│ │ Service │ │ • tasksWatcher │ │ +│ │ (parses stdout) │ │ • teamsWatcher │ │ +│ │ │ └───────────┬───────────┘ │ +│ │ • captureSendMsg │ │ │ +│ │ • captureSpawnEvt │ TeamChangeEvent { type: 'task' } │ +│ │ • detectSessionId │ │ │ +│ └─────────────────────┘ ┌───────────▼───────────┐ │ +│ │ wireFileWatcherEvents │ │ +│ │ (src/main/index.ts) │ │ +│ ┌──────────────────────┐ └───────────┬───────────┘ │ +│ │ TeamTaskReader │ │ │ +│ │ (re-reads all .json) │◄─────────────┤ │ +│ │ │ │ │ +│ │ TeamKanbanManager │ IPC: TEAM_CHANGE │ +│ │ (reads kanban-state) │ │ │ +│ └──────────────────────┘ │ │ +│ │ │ +│ ┌──────────────────────┐ │ │ +│ │ TeamTaskWriter │ │ │ +│ │ (UI-initiated writes) │ │ │ +│ └──────────────────────┘ │ │ +└────────────────────────────────────────┼────────────────────────────┘ + │ + IPC (webContents.send) + │ +┌────────────────────────────────────────┼────────────────────────────┐ +│ Renderer (React + Zustand) │ │ +│ │ │ +│ team-change event → refetch tasks via IPC → update Zustand store │ +│ → re-render KanbanBoard │ +│ │ +└────────────────────────────────────────────────────────────────────┘ + +(*) TaskCreate — Claude's native tool, deprioritized by prompt. + Writes to same location but lacks our rich metadata. +``` + +--- + +## 8. Key Questions Answered + +### Does Claude currently use MCP for kanban management? + +**YES.** Claude uses our `agent-teams-mcp` MCP server for ALL task board operations. The server is injected via `--mcp-config` when spawning the CLI process. Claude's native `TaskCreate` is not blocked but is explicitly deprioritized ("optional for private planning only") via the system prompt. + +### How does task data flow? + +1. **Claude calls MCP tool** (e.g., `task_create`) via the stdio MCP transport +2. **agent-teams-controller** writes a JSON file to `~/.claude/tasks//.json` (atomic write via temp+rename) +3. **fs.watch()** in FileWatcher detects the change (100ms debounce) +4. **TeamChangeEvent** `{ type: 'task', teamName, detail: '.json' }` emitted +5. **wireFileWatcherEvents()** forwards to renderer via IPC (`webContents.send('team:change', event)`) +6. **Renderer** re-fetches full task list via IPC → `TeamTaskReader.getTasks()` re-reads all JSON files +7. **Zustand store** updates → React components re-render + +### Could we replace Claude's built-in tools with MCP tools? + +**We already did, effectively.** Claude's built-in `TaskCreate`/`TaskUpdate`/`TaskList`/`TaskGet` are NOT blocked, but the prompt instructs Claude to use our MCP tools exclusively. The built-in `SendMessage` and `Agent` tools are still used (they handle inter-agent communication and teammate spawning — responsibilities our MCP server doesn't cover). + +What we CANNOT replace via MCP: +- `SendMessage` — this is Claude's native inter-agent messaging protocol +- `Agent` — this is the tool that spawns teammate subprocesses +- `TeamCreate` — this bootstraps the team structure + +### If Claude also used MCP (like Codex/Gemini would), would that unify the architecture? + +**Partially, but with important nuances:** + +**What's already unified:** +- The `agent-teams-controller` package is the single source of truth for task/kanban/review operations. Both the MCP server and the Electron main process import it. +- Any AI agent (Claude, Codex, Gemini) that connects to our MCP server gets the same tools and writes to the same files. + +**What would still differ per agent:** +- **Team spawning** — Claude uses `Agent(team_name=...)` which is proprietary. Other agents would need their own subprocess spawning mechanism. +- **Inter-agent messaging** — Claude uses `SendMessage` (part of its Agent Teams protocol). Other agents would need a different approach (perhaps MCP-based `send_message` tool). +- **Process lifecycle** — Claude's `--input-format stream-json` / `--output-format stream-json` keeps the CLI alive. Other agents would need different process management. +- **Prompt injection** — Our provisioning prompt is Claude-specific. Other agents would need their own system prompts. + +**To truly unify for multi-agent support:** +1. The MCP server already provides all task/kanban operations — any agent with MCP support can use them +2. We'd need to add MCP tools for messaging (`send_message`, `read_inbox`) to replace Claude-specific `SendMessage` +3. We'd need a generic agent spawning mechanism (not Claude's `Agent` tool) +4. The stdout parsing in `TeamProvisioningService` is Claude-specific — other agents would need different adapters + +--- + +## 9. File Index + +| File | Role | +|------|------| +| `src/main/services/team/TeamProvisioningService.ts` | Spawns Claude CLI, attaches stdout parser, handles stream-json, manages team lifecycle | +| `src/main/services/team/TeamMcpConfigBuilder.ts` | Generates `--mcp-config` JSON file that injects our MCP server | +| `mcp-server/src/index.ts` | FastMCP server entry point (stdio transport) | +| `mcp-server/src/controller.ts` | Wraps `agent-teams-controller` for MCP tools | +| `mcp-server/src/tools/taskTools.ts` | Task CRUD MCP tools (17 tools) | +| `mcp-server/src/tools/kanbanTools.ts` | Kanban state MCP tools (6 tools) | +| `mcp-server/src/tools/reviewTools.ts` | Review workflow MCP tools | +| `agent-teams-controller/src/controller.js` | Shared controller factory — creates context + binds all domain modules | +| `agent-teams-controller/src/internal/taskStore.js` | Low-level task JSON file read/write operations | +| `agent-teams-controller/src/internal/tasks.js` | Task business logic (create, start, complete, comment, etc.) | +| `agent-teams-controller/src/internal/runtimeHelpers.js` | Path resolution (`~/.claude/tasks/`, `~/.claude/teams/`) | +| `src/main/services/infrastructure/FileWatcher.ts` | Watches `~/.claude/tasks/` and `~/.claude/teams/` with fs.watch() | +| `src/main/index.ts` (lines 425-620) | `wireFileWatcherEvents()` — forwards file changes to renderer via IPC | +| `src/main/services/team/TeamTaskReader.ts` | Reads all task JSON files, normalizes, returns `TeamTask[]` | +| `src/main/services/team/TeamTaskWriter.ts` | UI-side writes (create, update status, add comment, etc.) | +| `src/main/services/team/TeamKanbanManager.ts` | Reads/writes `kanban-state.json` for UI kanban overlay | diff --git a/docs/research/cli-adapter-exhaustive-search.md b/docs/research/cli-adapter-exhaustive-search.md new file mode 100644 index 00000000..8dd76217 --- /dev/null +++ b/docs/research/cli-adapter-exhaustive-search.md @@ -0,0 +1,305 @@ +# Exhaustive Search: Unified CLI Agent Adapter Libraries + +**Date:** 2026-03-24 +**Goal:** Find ANY existing library/package that provides a unified interface for spawning and communicating with multiple AI coding CLI agents (Claude Code, Codex, Gemini CLI, Goose, Aider, OpenCode, etc.) +**Verdict:** Multiple viable options now exist. The landscape has changed dramatically since last check. + +--- + +## Executive Summary + +The "nothing exists" conclusion from previous research is **no longer accurate**. As of March 2026, there are at least **6 serious contenders** that provide a unified interface for controlling multiple CLI coding agents. The ecosystem exploded in late 2025 / early 2026 driven by the Agent Client Protocol (ACP) standard and the proliferation of CLI coding agents. + +However, **none of them are a drop-in library for Electron** in the way we need. Each has tradeoffs. The analysis below is ordered from most to least relevant for our use case. + +--- + +## Tier 1: Directly Relevant — Unified Agent Interface Libraries + +### 1. Rivet Sandbox Agent SDK +- **Repo:** https://github.com/rivet-dev/sandbox-agent +- **npm:** `@sandbox-agent/cli` (v0.2.x), `sandbox-agent` (TS SDK) +- **Website:** https://sandboxagent.dev +- **Language:** Rust server + TypeScript SDK +- **Supported agents:** Claude Code, Codex, OpenCode, Cursor, Amp, Pi (6 agents) +- **Last activity:** Active (HN launch Feb 2026) +- **Stars:** High interest (featured on InfoQ, HN front page) +- **TypeScript types:** Yes, full TypeScript SDK with embedded mode +- **Installable via npm:** Yes +- **Can embed in Electron:** Partially. The TS SDK can spawn the Rust binary as a subprocess. However, it's designed for sandboxed environments (Docker, E2B, Daytona), not local Electron apps. +- **How it works:** Rust HTTP server runs inside a sandbox, exposes unified REST + SSE API. TS SDK connects over HTTP or spawns daemon. +- **Universal session schema:** Yes — normalizes all agent events into consistent format (session lifecycle, items, questions, permissions) +- **Reliability:** 8/10 — Backed by Rivet (YC company), clean architecture +- **Confidence this fits our needs:** 5/10 — Sandbox-first design doesn't map well to local Electron. We'd need to run the binary locally without a sandbox. The TS SDK embed mode is promising but untested for our use case. + +### 2. Agent Client Protocol (ACP) + TypeScript SDK +- **Repo:** https://github.com/agentclientprotocol/typescript-sdk +- **npm:** `@agentclientprotocol/sdk` (v0.14.1, 245 dependents) +- **Spec:** https://agentclientprotocol.com +- **Language:** TypeScript +- **Supported agents:** 25+ agents (Claude, Codex, Gemini CLI, Copilot, Goose, OpenCode, Pi, Kiro, Junie, Cline, OpenHands, Qoder, Kimi, and many more) +- **Last publish:** 15 days ago (very active) +- **Stars:** Growing rapidly (Zed-backed, GitHub Copilot adopted it) +- **TypeScript types:** Yes, full TypeScript SDK +- **Installable via npm:** Yes +- **Can embed in Electron:** Yes. The SDK provides `ClientSideConnection` that connects to agents via stdio or TCP. You spawn the agent CLI process and pipe stdio — exactly like what we do now with Claude Code. +- **How it works:** Standardized JSON-RPC protocol over stdio/TCP. Each agent implements ACP server. Client spawns process, communicates via NDJSON. +- **Reliability:** 9/10 — Backed by Zed Industries, adopted by GitHub Copilot CLI, Gemini CLI, Goose, and 20+ agents. This is becoming the industry standard. +- **Confidence this fits our needs:** 7/10 — This is the most promising approach. However: not all agents support ACP natively yet (Claude Code's ACP support is via adapter, not native). The protocol covers editor-agent communication, which is close to but not identical to our CLI orchestration needs. +- **Critical note:** ACP is about standardizing the *protocol* between a client and an agent. It does NOT handle process spawning, worktree management, or team coordination — we'd still build that ourselves on top. + +### 3. @posthog/code-agent +- **Repo:** https://github.com/PostHog/code (monorepo) +- **npm:** `@posthog/code-agent` (v0.2.0) +- **Language:** TypeScript +- **Supported agents:** Claude Code (Anthropic), OpenAI Codex (2 agents) +- **Last publish:** ~3 months ago +- **Stars:** Part of PostHog's code monorepo +- **TypeScript types:** Yes, full TypeScript +- **Installable via npm:** Yes +- **Can embed in Electron:** Yes — it's a pure TypeScript library +- **How it works:** Wraps Anthropic Claude Agent SDK and OpenAI Codex SDK behind a unified interface. Single API for streaming events, tool calls, diffs, permissions. +- **Features:** Unified permissions (strict/auto/permissive), MCP bridge, diff normalization, streaming events, auth discovery +- **Reliability:** 6/10 — Only 2 providers, no community adoption (0 dependents), published by PostHog for their own products +- **Confidence this fits our needs:** 4/10 — Too limited (only 2 agents). Uses official SDKs (not CLI spawn), which means it talks to APIs, not CLI processes. Different paradigm from what we need. + +### 4. one-agent-sdk +- **Repo:** https://github.com/odysa/one-agent-sdk +- **Language:** TypeScript +- **Supported agents:** Claude Code, Codex, Kimi CLI (3 agents) +- **TypeScript types:** Yes +- **Installable via npm:** Appears to be (uses official provider SDKs) +- **Can embed in Electron:** Yes — pure TypeScript +- **How it works:** Wraps official SDKs (@anthropic-ai/claude-agent-sdk, @openai/codex-sdk, @moonshot-ai/kimi-agent-sdk) behind unified interface. Provider-agnostic tools, handoffs, middleware. +- **Reliability:** 4/10 — Very new, minimal community, only 3 providers +- **Confidence this fits our needs:** 3/10 — Same limitation as @posthog/code-agent: uses SDKs not CLI spawn. Only 3 agents. Too narrow. + +### 5. Coder AgentAPI +- **Repo:** https://github.com/coder/agentapi +- **Language:** Go (server), OpenAPI 3.0.3 spec available +- **Supported agents:** Claude Code, Goose, Aider, Gemini, Amp, Codex (6 agents) +- **Stars:** ~996 +- **Latest version:** v0.11.2 +- **TypeScript types:** No official TS SDK, but OpenAPI spec available for generation +- **Installable via npm:** No (Go binary) +- **Can embed in Electron:** Partially. We'd bundle the Go binary and spawn it as subprocess. +- **How it works:** Runs an in-memory terminal emulator. Translates API calls into terminal keystrokes, parses agent outputs into messages. Simple 4-endpoint REST API (POST /message, GET /status, GET /events SSE, GET /messages). +- **Reliability:** 7/10 — Built by Coder (well-funded company), clean design, but terminal emulation approach has inherent limitations +- **Confidence this fits our needs:** 5/10 — Terminal emulation is clever but fragile. We'd need to bundle a Go binary. No native TypeScript SDK. Could generate one from OpenAPI spec. + +--- + +## Tier 2: Standalone Apps with Adapter Architecture (Not Reusable Libraries) + +These projects have interesting adapter/plugin architectures but are **standalone applications**, not importable libraries. + +### 6. Overstory +- **Repo:** https://github.com/jayminwest/overstory +- **Language:** TypeScript (Bun runtime) +- **Architecture:** Pluggable `AgentRuntime` interface at `src/runtimes/types.ts` +- **Supported runtimes:** 11 (Claude Code, Pi, Gemini CLI, Aider, Goose, Amp, and custom) +- **Stars:** Growing +- **Reusable as library:** No. It's a CLI orchestrator (Bun-only, uses tmux). The `AgentRuntime` interface is embedded in the app, not published as a package. +- **Relevance:** The `AgentRuntime` interface design is good reference material for our own adapter pattern. Worth studying `src/runtimes/types.ts`. + +### 7. conductor-oss (by charannyk06) +- **Repo:** https://github.com/charannyk06/conductor-oss +- **npm:** `conductor-oss` (launcher only) +- **Language:** Rust backend + TypeScript frontend (Next.js dashboard) +- **Architecture:** `conductor-executors` crate contains adapters for 10 agents +- **Supported agents:** Claude Code, Codex, Gemini, Qwen Code, Cursor Agent, Amp, OpenCode, Copilot, CCR (10 agents) +- **Reusable as library:** No. The agent adapters are Rust code in a Rust crate. The npm package is just a launcher that starts the Rust server. +- **Relevance:** Good reference for agent adapter patterns. The adapter architecture handles binary detection, launch commands, process monitoring, and prompt delivery. + +### 8. Vibe Kanban +- **Repo:** https://github.com/BloopAI/vibe-kanban +- **npm:** `vibe-kanban` (npx wrapper) +- **Stars:** ~23.4k +- **Language:** Rust backend + TypeScript/React frontend +- **Architecture:** "Executor" plugin pattern for each agent +- **Supported agents:** 10+ (Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, Qwen Code) +- **Reusable as library:** No. Executors are Rust code. TypeScript types are generated from Rust via ts-rs. +- **Relevance:** Closest competitor to our product. Their agent adapter pattern is in Rust, not reusable by us. But: there is a community TypeScript port `@nogataka/coding-agent-mgr` that claims to be a drop-in replacement — worth investigating. + +### 9. Dorothy +- **Repo:** https://github.com/Charlie85270/Dorothy +- **Language:** Electron + React/Next.js +- **Architecture:** Agent Manager using node-pty +- **Supported agents:** Claude Code, Codex, Gemini +- **Reusable as library:** No. Standalone Electron desktop app. +- **Relevance:** Very similar architecture to ours (Electron + node-pty). Good reference for how they handle agent spawning. MCP server integration is interesting. + +### 10. Emdash +- **Repo:** https://github.com/generalaction/emdash +- **Backed by:** Y Combinator W26 +- **Language:** Electron + TypeScript +- **Supported agents:** 23 CLI providers +- **Reusable as library:** No. Standalone Electron app with SQLite/Drizzle. +- **Relevance:** Most similar to our product architecture-wise (Electron + TypeScript). Supports 23 agents. Worth studying their provider integration code for patterns. Auto-detects installed CLIs. + +### 11. ComposioHQ Agent Orchestrator +- **Repo:** https://github.com/ComposioHQ/agent-orchestrator +- **npm:** `@composio/ao` (global CLI) +- **Language:** TypeScript (40,000 LOC) +- **Architecture:** 8 plugin slots (runtime, agent, workspace, tracker, SCM, notifier, terminal, lifecycle) +- **Supported agents:** Claude Code, Codex, Aider (and more via plugins) +- **Reusable as library:** Partially. The plugin interfaces are TypeScript, but the system is designed as a standalone CLI orchestrator. +- **Stars:** Growing (17 plugins, 3,288 tests) +- **Relevance:** The TypeScript plugin interface pattern could be extracted/adapted. + +### 12. Parallel Code +- **Repo:** https://github.com/johannesjo/parallel-code +- **Language:** Desktop app (unspecified stack) +- **Supported agents:** Claude Code, Codex CLI, Gemini CLI +- **Reusable as library:** No. Standalone desktop app. + +--- + +## Tier 3: MCP-Based Orchestrators (Different Paradigm) + +### 13. all-agents-mcp +- **Repo:** https://github.com/Dokkabei97/all-agents-mcp +- **npm:** `all-agents-mcp` (npx) +- **Language:** TypeScript +- **Supported agents:** Claude Code, Codex, Gemini CLI, Copilot CLI (4 agents) +- **Architecture:** MCP server with agent abstraction layer (`src/agents/types.ts`, `base-agent.ts`, per-agent adapters) +- **Reusable as library:** Partially. The agent abstraction layer (`src/agents/`) could be extracted. But it's designed as an MCP server, not a library. +- **TypeScript types:** Yes +- **Relevance:** The `src/agents/` directory contains a clean TypeScript agent abstraction with `types.ts`, `base-agent.ts`, and per-agent implementations. This is the closest to a reusable adapter pattern in pure TypeScript. + +### 14. agents-mcp (d-kimuson) +- **Repo:** https://github.com/d-kimuson/agents-mcp +- **Description:** MCP server for unified AI agents interface +- **Relevance:** Minimal info, likely similar pattern to all-agents-mcp + +--- + +## Tier 4: Protocols / Standards (Not Libraries, But Important Context) + +### 15. Agent Client Protocol (ACP) +- **Spec:** https://agentclientprotocol.com +- **Repo:** https://github.com/agentclientprotocol/agent-client-protocol +- **Created by:** Zed Industries +- **Adopted by:** GitHub Copilot CLI, Gemini CLI, Goose, Pi, OpenClaw, OpenCode, Cline, Codex, and 20+ agents +- **TypeScript SDK:** `@agentclientprotocol/sdk` (v0.14.1, 245 dependents, published 15 days ago) +- **This is becoming THE standard.** JSON-RPC over stdio/TCP. Editor spawns agent process, communicates via NDJSON. +- **Key insight:** If most agents converge on ACP, our adapter layer becomes simpler — we just need an ACP client. + +### 16. agent-protocol (AI Engineers Foundation) +- **npm:** `agent-protocol` (v1.0.5) +- **Last published:** 2 years ago (dead) +- **Relevance:** Superseded by ACP. Not relevant. + +--- + +## Tier 5: Tangentially Related (Process Management / Terminal Control) + +### 17. terminalcp (@mariozechner/terminalcp) +- **Repo:** https://github.com/badlogic/terminalcp +- **npm:** `@mariozechner/terminalcp` +- **What:** "Playwright for the terminal" — MCP server that lets agents spawn and interact with any CLI tool +- **Uses:** node-pty + xterm.js for terminal emulation +- **Relevance:** Not an agent adapter, but the terminal spawn/control pattern (node-pty + xterm.js + Unix socket daemon) is exactly what we'd use if building our own. + +### 18. Network-AI +- **Repo:** https://github.com/jovanSAPFIONEER/Network-AI +- **npm:** `network-ai` +- **Language:** TypeScript +- **What:** Multi-agent orchestrator with 14 adapters (LangChain, AutoGen, CrewAI, OpenAI, etc.) +- **Relevance:** The adapters are for AI *frameworks*, not CLI coding agents. Different domain. + +### 19. execa +- **npm:** `execa` (millions of weekly downloads) +- **What:** Process execution for humans. Wrapper around child_process. +- **Relevance:** Not agent-specific, but the best foundation for spawning CLI processes in Node.js. We already use this pattern. + +--- + +## Comprehensive Comparison Matrix + +| Project | Type | npm pkg? | TS types? | Agents | Electron-safe? | Active? | Our fit | +|---------|------|----------|-----------|--------|-----------------|---------|---------| +| **ACP SDK** | Protocol SDK | Yes | Yes | 25+ | Yes | Very | **Best** | +| **Sandbox Agent SDK** | Unified API | Yes | Yes | 6 | Partial | Active | Good | +| **@posthog/code-agent** | SDK wrapper | Yes | Yes | 2 | Yes | Stale | Poor | +| **one-agent-sdk** | SDK wrapper | Yes | Yes | 3 | Yes | New | Poor | +| **Coder AgentAPI** | HTTP server | No (Go) | OpenAPI | 6 | Partial | Active | OK | +| **all-agents-mcp** | MCP server | Yes | Yes | 4 | Partial | Active | Reference | +| **Overstory** | CLI app | No | Yes | 11 | No (Bun+tmux) | Active | Reference | +| **conductor-oss** | App | Launcher | Rust | 10 | No (Rust) | Active | Reference | +| **Vibe Kanban** | App | Wrapper | Generated | 10+ | No (Rust) | Active | Reference | +| **Dorothy** | Electron app | No | Yes | 3+ | Same arch | Active | Reference | +| **Emdash** | Electron app | No | Yes | 23 | Same arch | Active | Reference | +| **ComposioHQ AO** | CLI app | Global | Yes | 3+ | Partial | Active | Reference | + +--- + +## Recommendation + +### Best Option: ACP SDK (`@agentclientprotocol/sdk`) +- **Reliability:** 9/10 +- **Confidence:** 7/10 + +**Why:** ACP is becoming the industry standard. 25+ agents support it. Backed by Zed, adopted by GitHub Copilot. The TypeScript SDK is mature (v0.14.1, 245 dependents). It handles the protocol layer — we handle process spawning and team coordination on top. + +**Risk:** Claude Code's ACP support is via adapter (not native stream-json). We'd need to verify Claude Code works with ACP in our specific use case (Agent Teams, stream-json mode). The protocol focuses on editor-agent communication, not CLI orchestration. + +### Fallback: Build Our Own Adapter Layer +- **Reliability:** 8/10 +- **Confidence:** 9/10 + +**Why:** Given that: +1. No library perfectly fits our Electron + Agent Teams architecture +2. The adapter layer is relatively thin (spawn process, pipe stdio, parse output) +3. We already have a working Claude Code integration via stream-json +4. ACP can be adopted incrementally as agents converge on it + +We should define our own `IAgentRuntime` interface (inspired by Overstory's `AgentRuntime` and ACP's `AgentSideConnection`), implement Claude Code adapter first, then add ACP-based adapters for other agents. + +### Reference implementations to study: +1. **ACP TypeScript SDK** — Protocol design, event schema, NDJSON streaming +2. **Overstory `src/runtimes/types.ts`** — AgentRuntime interface design for CLI agents +3. **all-agents-mcp `src/agents/`** — Clean TypeScript agent abstraction with base class +4. **Emdash provider integration** — How they handle 23 agents in Electron +5. **Sandbox Agent SDK event schema** — Universal session schema for normalizing agent events + +--- + +## Sources + +### Tier 1 (Libraries/SDKs) +- [Rivet Sandbox Agent SDK](https://github.com/rivet-dev/sandbox-agent) | [Docs](https://sandboxagent.dev/) | [InfoQ](https://www.infoq.com/news/2026/02/rivet-agent-sandbox-sdk/) +- [ACP TypeScript SDK](https://github.com/agentclientprotocol/typescript-sdk) | [npm](https://www.npmjs.com/package/@agentclientprotocol/sdk) | [Spec](https://agentclientprotocol.com) +- [@posthog/code-agent](https://www.npmjs.com/package/@posthog/code-agent) | [PostHog/code](https://github.com/PostHog/code) +- [one-agent-sdk](https://github.com/odysa/one-agent-sdk) +- [Coder AgentAPI](https://github.com/coder/agentapi) + +### Tier 2 (Apps with Adapter Architecture) +- [Overstory](https://github.com/jayminwest/overstory) +- [conductor-oss](https://github.com/charannyk06/conductor-oss) | [npm](https://www.npmjs.com/package/conductor-oss) +- [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | [npm](https://www.npmjs.com/package/vibe-kanban) +- [Dorothy](https://github.com/Charlie85270/Dorothy) | [Site](https://dorothyai.app/) +- [Emdash](https://github.com/generalaction/emdash) | [Site](https://www.emdash.sh/) +- [ComposioHQ Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) +- [Parallel Code](https://github.com/johannesjo/parallel-code) + +### Tier 3 (MCP Orchestrators) +- [all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) + +### Tier 4 (Protocols) +- [Agent Client Protocol](https://agentclientprotocol.com) | [GitHub](https://github.com/agentclientprotocol/agent-client-protocol) | [Copilot ACP](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) +- [AI Code Agents SDK](https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/) (Vercel AI SDK based, early stage) + +### Tier 5 (Process/Terminal) +- [terminalcp](https://github.com/badlogic/terminalcp) | [npm](https://www.npmjs.com/package/@mariozechner/terminalcp) +- [Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) + +### Curated Lists +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) + +### HN Discussions +- [Show HN: Sandbox Agent SDK](https://news.ycombinator.com/item?id=46795584) +- [Show HN: OpenSwarm](https://news.ycombinator.com/item?id=47160980) +- [Show HN: Bridge from Copilot SDK to ACP](https://news.ycombinator.com/item?id=47165572) +- [Ask HN: Why CLI coding agents?](https://news.ycombinator.com/item?id=45115303) diff --git a/docs/research/competitor-spawn-patterns.md b/docs/research/competitor-spawn-patterns.md new file mode 100644 index 00000000..73471f08 --- /dev/null +++ b/docs/research/competitor-spawn-patterns.md @@ -0,0 +1,523 @@ +# Competitor Agent Spawn Patterns Research + +**Date**: 2026-03-25 + +## Executive Summary + +Все 4 конкурента построили собственные adapter-слои для spawning CLI-агентов. Ни один не использует готовую библиотеку. Паттерн единый: **интерфейс/trait + per-agent реализация + config-driven overrides**. + +Самый зрелый и переиспользуемый паттерн у **vibe-kanban** (Rust trait `StandardCodingAgentExecutor` + `enum_dispatch` + ACP harness). У **Emdash** паттерн проще (per-service TypeScript классы + auto-discovery). **Dorothy** самый примитивный (node-pty напрямую). **Superset** закрыт ELv2 лицензией. + +--- + +## 1. Vibe Kanban (BloopAI) + +**Repo**: [github.com/BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) +**Язык**: Rust (backend) + TypeScript (frontend) +**Лицензия**: Apache-2.0 +**Stars**: ~23K +**Поддерживаемые агенты**: Claude Code, Codex, Gemini CLI, Copilot, Amp, Cursor, OpenCode, Droid, QwenCode, Qoder + +### Архитектура + +Самый архитектурно зрелый подход среди конкурентов. + +**Ядро** — Rust trait + enum_dispatch: + +```rust +#[async_trait] +#[enum_dispatch(CodingAgent)] +pub trait StandardCodingAgentExecutor { + async fn spawn(&self, current_dir: &Path, prompt: &str, + env: &ExecutionEnv) -> Result; + + async fn spawn_follow_up(&self, current_dir: &Path, prompt: &str, + session_id: &str, reset_to_message_id: Option<&str>, + env: &ExecutionEnv) -> Result; + + async fn spawn_review(&self, current_dir: &Path, prompt: &str, + session_id: Option<&str>, env: &ExecutionEnv) + -> Result; +} +``` + +**Dispatch через enum** (compile-time, zero-cost): + +```rust +#[enum_dispatch] +#[derive(Clone, Serialize, Deserialize, PartialEq, TS, Display)] +pub enum CodingAgent { + ClaudeCode, Amp, Gemini, Codex, Opencode, + CursorAgent, QwenCode, Copilot, Droid, QaMock +} +``` + +### Структура файлов + +``` +crates/executors/src/ + executors/ + mod.rs — trait + enum_dispatch + CodingAgent enum + claude.rs — ClaudeCode executor + gemini.rs — Gemini executor (через ACP harness) + codex.rs — Codex executor + amp.rs — Amp executor + copilot.rs — GitHub Copilot + cursor.rs — Cursor Agent + opencode.rs — OpenCode + droid.rs — Droid (factory.ai) + qwen.rs — QwenCode + qa_mock.rs — Mock для тестов + utils.rs — Общие утилиты + acp/ — ACP (Agent Communication Protocol) harness + mod.rs + client.rs — ACP client + harness.rs — Spawn + session lifecycle + session.rs — Session management + normalize_logs.rs — Log normalization + claude/ — Claude-specific subdirectory + codex/ — Codex-specific subdirectory + cursor/ — Cursor-specific subdirectory + droid/ — Droid-specific subdirectory + opencode/ — OpenCode-specific subdirectory + command.rs — CommandBuilder (base cmd + params + overrides) + env.rs — ExecutionEnv (env vars, repo context) + executor_discovery.rs — Auto-discovery + caching + mcp_config.rs — MCP server config per agent + model_selector.rs — Model selection logic + profile.rs — Agent profiles (DEFAULT, APPROVALS variants) + approvals.rs — Permission/approval system + stdout_dup.rs — Stdout duplication utilities + lib.rs — Crate root +``` + +### Как добавить нового агента (на примере Qoder PR #1759) + +1. `crates/executors/src/executors/qoder.rs` — реализация trait +2. `mod.rs` — добавить в `CodingAgent` enum +3. `mcp_config.rs` — MCP Passthrough adapter +4. `default_profiles.json` — профили (DEFAULT/APPROVALS) +5. `generate_types.rs` — ts-rs type generation +6. `shared/types.ts` — TypeScript enum (автогенерация) +7. `shared/schemas/qoder.json` — JSON schema +8. `docs/agents/qoder.mdx` — документация + +### Пример: Gemini executor + +``` +Base command: "npx -y @google/gemini-cli@0.29.3" +Flags: --experimental-acp, --model , --yolo (if auto mode) +Harness: AcpAgentHarness — manages spawn, follow-up, session lifecycle +Output: ACP protocol (structured agent communication) +``` + +### Ключевые паттерны + +- **CommandBuilder** (builder pattern): base cmd → params → overrides → platform-specific split → CommandParts +- **ExecutionEnv**: HashMap env vars + repo context, inject into tokio::Command +- **CmdOverrides**: replace base cmd / append params / set env vars (per-profile) +- **ACP Harness**: shared session/spawn logic for ACP-compatible agents (Gemini, Qoder, etc.) +- **executor_discovery**: async discovery + caching по (path, command_key, agent_type) +- **ts-rs**: Rust types → TypeScript types автогенерация + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~2000-3000 (весь crate executors) | +| Паттерн | trait + enum_dispatch + CommandBuilder | +| Сложность добавления агента | ~150-200 LOC per agent | +| Можно переиспользовать? | Нет (Rust, другой стек) | +| Можно скопировать паттерн? | ДА — отличный reference | +| Надёжность подхода | 9/10 | + +--- + +## 2. Emdash (General Action) + +**Repo**: [github.com/generalaction/emdash](https://github.com/generalaction/emdash) +**Язык**: TypeScript (Electron) +**Лицензия**: MIT +**Stars**: ~6K +**YC W26** +**Поддерживаемые агенты**: 22+ (Claude Code, Codex, Gemini, Amp, Cursor, Copilot, Goose, Droid, Kiro, Qwen, OpenCode, Cline, Continue, Codebuff, Charm, Kilocode, Kimi, Autohand, Auggie, Rovo Dev, Mistral Vibe, Pi) + +### Архитектура + +Per-service TypeScript классы + auto-discovery. Самый близкий к нашему стеку. + +**Ключевые файлы**: + +``` +src/main/services/ + CodexService.ts — Manages Codex CLI child processes + log streaming + CodexSessionService.ts — Session management for Codex + ClaudeConfigService.ts — Claude-specific configuration + ClaudeHookService.ts — Claude hooks integration + AgentEventService.ts — Agent lifecycle events + TaskLifecycleService.ts — Task state machine + WorkspaceProviderService.ts — Provider workspace management + TerminalConfigParser.ts — CLI terminal config detection + ptyManager.ts — PTY session management (node-pty) + ptyIpc.ts — PTY IPC communication + ConnectionsService.ts — Connection management + RepositoryManager.ts — Git repository management + ProjectSettingsService.ts + DatabaseService.ts — SQLite (drizzle) + AutoUpdateService.ts + __tests__/ — Tests + fs/ — File system services + git-core/ — Git operations + mcp/ — MCP protocol + skills/ — Skills system + ssh/ — SSH remote development +``` + +### Как добавить провайдера (из документации) + +1. Include: provider name, CLI invocation command, auth notes, setup steps +2. Team wires up provider selection in UI and adds to Integrations matrix +3. Providers auto-detected when CLI is in PATH + +### Spawn-паттерн + +- `node:child_process.spawn()` для CLI агентов +- `node-pty` для terminal sessions +- Per-service классы (CodexService, ClaudeConfigService) +- `TerminalConfigParser` для auto-detection CLI в PATH +- `AgentEventService` для lifecycle events (running/waiting/completed/error) +- SQLite (drizzle ORM) для персистенции + +### Ключевые особенности + +- **Auto-discovery**: провайдеры детектятся автоматически по наличию CLI в PATH +- **Native deps**: sqlite3, node-pty, keytar (rebuilt per Electron version) +- **Worktree isolation**: каждый агент в своём git worktree +- **SSH remote**: агенты могут работать на удалённых машинах через SSH/SFTP +- **Best-of-N**: запуск нескольких агентов на одну задачу, выбор лучшего + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~1500-2000 (services + pty) | +| Паттерн | Per-service classes + auto-discovery | +| Сложность добавления агента | Средняя (новый service file) | +| Можно переиспользовать код? | Потенциально ДА (MIT, TypeScript, Electron) | +| Можно скопировать паттерн? | ДА — очень близко к нашему стеку | +| Надёжность подхода | 7/10 (less structured than vibe-kanban) | + +--- + +## 3. Dorothy (Charlie85270) + +**Repo**: [github.com/Charlie85270/Dorothy](https://github.com/Charlie85270/Dorothy) +**Язык**: TypeScript (Electron + Next.js) +**Лицензия**: MIT +**Stars**: ~3K +**Поддерживаемые агенты**: Claude Code (primarily), расширяется + +### Архитектура + +Самый простой подход — node-pty напрямую, без абстракции adapter layer. + +``` +electron/ + agent-manager.ts — Agent lifecycle & parallel execution (node-pty) + pty-manager.ts — Terminal session multiplexing + window-manager.ts — Window management + services/ + telegram-bot + slack-bot + kanban-automation + mcp-server-launcher + api-server +mcp-orchestrator/ — Super Agent MCP server +mcp-kanban/ — Kanban automation MCP +``` + +### Spawn-паттерн + +- `node-pty` — каждый агент в изолированной PTY-сессии +- Статус определяется парсингом stdout patterns (running/waiting/completed/error) +- N параллельных агентов с отдельными проектами +- Super Agent (мета-агент) контролирует другие через MCP tools +- Cron-based scheduling для повторяющихся задач +- Skills system для extensibility + +### Ключевые особенности + +- **Нет абстракции агентов**: привязан к Claude Code, нет interface для разных CLI +- **Kanban**: задачи → колонки → automatic agent assignment по skills +- **Automations**: GitHub PR/issue polling → agent spawning +- **Remote control**: Telegram/Slack bot для управления + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~500-800 (agent-manager + pty-manager) | +| Паттерн | Direct node-pty, no abstraction | +| Сложность добавления агента | Высокая (нет interface) | +| Можно переиспользовать код? | Да (MIT), но мало что полезного | +| Можно скопировать паттерн? | НЕТ — слишком примитивный | +| Надёжность подхода | 5/10 | + +--- + +## 4. Superset (superset-sh) + +**Repo**: [github.com/superset-sh/superset](https://github.com/superset-sh/superset) +**Язык**: TypeScript (Electron, monorepo Turborepo + Bun) +**Лицензия**: Elastic License 2.0 (ELv2) — НЕ open-source! +**Stars**: ~7.8K +**Поддерживаемые агенты**: Claude Code, Codex, Aider, Copilot, Cursor Agent, Gemini CLI, OpenCode + custom + +### Архитектура + +Monorepo с 6 apps. Multi-process Electron с 5 entry points: + +``` +apps/desktop/src/ + main/ + index.ts — Main app entry + terminal-host/ + index.ts — Persistent daemon for terminal sessions + pty-subprocess.ts — PTY handler (node-pty) + git-task-worker.ts — Worker thread for Git ops + host-service/ + index.ts — Local HTTP server + +packages/ + @superset/trpc — tRPC routers + @superset/ui — Shared React components + @superset/local-db — SQLite (Drizzle) + @superset/db — PostgreSQL (Neon, cloud sync) +``` + +### Spawn-паттерн + +- **Terminal Host daemon**: persistent subprocess managing terminal sessions +- **PTY subprocess**: node-pty forked on-demand +- **Git Worker**: heavy git ops offloaded to worker_threads +- **tRPC over Electron IPC**: renderer ↔ main communication +- **Worktree isolation**: каждая задача в своём git worktree с уникальным branch +- **Port allocation**: SUPERSET_PORT_BASE + 20 портов на workspace + +### Ключевые особенности + +- **Multi-process**: 5 entry points, daemon-based terminal management +- **Dual DB**: local SQLite + cloud PostgreSQL (ElectricSQL sync) +- **Better Auth**: OAuth deep links для десктопа +- **.superset/config.json**: workspace setup/teardown scripts + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | Неизвестно (code not browsable) | +| Паттерн | Multi-process + terminal-host daemon | +| Сложность добавления агента | Неизвестно | +| Можно переиспользовать код? | НЕТ (Elastic License 2.0) | +| Можно скопировать паттерн? | Частично (terminal-host daemon idea) | +| Надёжность подхода | 8/10 (production, enterprise users) | + +--- + +## CLI Agent Programmatic Spawn Reference + +### Claude Code + +```bash +claude --input-format stream-json --output-format stream-json --verbose +``` + +- **Bidirectional NDJSON protocol** over stdin/stdout +- Message types: `initialize`, `user`, `control_response` +- `--verbose` REQUIRED with stream-json +- `--print` mode for one-shot (no multi-turn) +- Session hooks do NOT run in `--print` mode +- Official docs: [incomplete](https://github.com/anthropics/claude-code/issues/24594) — community reverse-engineered +- VS Code extension spawns: `claude --output-format stream-json --verbose --input-format stream-json --max-thinking-tokens 0 --model default --permission-prompt-tool stdio` + +### Codex (OpenAI) + +```bash +codex exec --json "prompt" +``` + +- **JSONL output** (one event per line) +- Event types: `thread.started`, `turn.started`, `turn.completed`, `turn.failed`, `item.*` +- Item types: agent_message, reasoning, command_exec, file_change, mcp_tool_call +- `--output-schema` for structured final output +- `codex exec resume --json` for session resumption +- **App-server mode**: `codex app-server` — stateful JSON-RPC over stdio +- Auth: `CODEX_API_KEY` env var for non-interactive +- Schema BREAKING CHANGES between versions (item_type → type, assistant_message → agent_message) + +### Gemini CLI (Google) + +```bash +gemini -p "prompt" --output-format json +# or streaming: +gemini -p "prompt" --output-format stream-json +``` + +- `-p` flag for non-interactive headless mode +- `--output-format json` — full response + stats +- `--output-format stream-json` — real-time JSONL events +- `--yolo` — auto-approve all tool calls +- `--experimental-acp` — Agent Communication Protocol (used by vibe-kanban) +- **Known issues**: response field may contain markdown-wrapped JSON instead of clean JSON +- Stdin piping supported for additional context + +### Amp (Sourcegraph) + +```bash +amp --execute "prompt" --stream-json +``` + +- `--stream-json` — JSONL output (REQUIRES `--execute`) +- `--stream-json-input` — JSONL input via stdin (REQUIRES `--stream-json`) +- `--stream-json-thinking` — includes thinking blocks (extends schema) +- **Claude Code compatible format** (mostly) +- Multi-turn: `amp threads continue [thread-id]` + `--stream-json-input` +- Auth: `AMP_API_KEY` env var for CI/CD +- Elixir SDK exists as reference: spawns CLI + parses stream-json + +### Goose (Block) + +```bash +goose run -t "prompt" +# or from file: +goose run -i instructions.md +``` + +- `goose run` — non-interactive one-shot mode +- `--output-format json` — [feature request #4419, marked Done](https://github.com/block/goose/issues/4419) +- `--format json` — for session/recipe listing +- Max 10 concurrent subagents (hard-coded) +- 5 min default timeout, 25 max turns +- `GOOSE_SUBAGENT_MAX_TURNS` env override + +--- + +## Сравнительная таблица + +| | Vibe Kanban | Emdash | Dorothy | Superset | +|---|---|---|---|---| +| **Язык** | Rust | TypeScript | TypeScript | TypeScript | +| **Лицензия** | Apache-2.0 | MIT | MIT | ELv2 | +| **Используют готовую библиотеку?** | Нет | Нет | Нет | Нет | +| **Паттерн** | trait + enum_dispatch | Per-service classes | Direct node-pty | Multi-process daemon | +| **Абстракция агентов** | `StandardCodingAgentExecutor` trait | Per-service (CodexService, etc.) | Нет | Terminal-host daemon | +| **Количество агентов** | 10+ | 22+ | 1 (Claude) | 8+ | +| **Сложность добавления** | ~150-200 LOC | ~300-500 LOC | Hard (no interface) | Unknown | +| **LOC adapter layer** | ~2000-3000 | ~1500-2000 | ~500-800 | Unknown | +| **Auto-discovery** | Да (executor_discovery) | Да (PATH detection) | Нет | Unknown | +| **MCP support** | Passthrough per agent | Да | MCP servers | Да | +| **ACP protocol** | Да (shared harness) | Нет | Нет | Нет | +| **Type generation** | ts-rs (Rust → TS) | N/A | N/A | N/A | +| **Isolation** | Git worktrees | Git worktrees | Separate projects | Git worktrees | +| **Можно reuse код?** | Нет (Rust) | Да (MIT, TS) | Да (MIT) | Нет (ELv2) | + +--- + +## Выводы и рекомендации для Claude Agent Teams UI + +### 1. Какой паттерн взять за основу? + +**Рекомендация: гибрид vibe-kanban + emdash подходов** + +От vibe-kanban взять: +- **Interface (trait) + per-agent implementation** — TypeScript interface вместо Rust trait +- **CommandBuilder pattern** — построение команды через builder с overrides +- **ExecutionEnv** — управление env vars + repo context +- **Profile system** — DEFAULT/APPROVALS варианты per agent +- **enum-dispatch idea** — в TS реализуется через discriminated union + factory + +От Emdash взять: +- **Auto-discovery** — детекция CLI в PATH +- **Per-service approach** — но с общим interface +- **node-pty integration** — для terminal sessions + +### 2. Предлагаемый TypeScript interface + +```typescript +interface AgentExecutor { + readonly agentType: AgentType; // discriminated union tag + + spawn(params: SpawnParams): Promise; + spawnFollowUp(params: FollowUpParams): Promise; + spawnReview?(params: ReviewParams): Promise; + + discover(): Promise; + isAvailable(): Promise; + + normalizeOutput(raw: string): string; + parseEvent(line: string): AgentEvent | null; +} + +interface SpawnParams { + workDir: string; + prompt: string; + env: ExecutionEnv; + model?: string; + approvalMode: 'auto' | 'supervised'; + mcpConfig?: string; +} + +interface SpawnedAgent { + process: ChildProcess; + sessionId: string; + stdout: ReadableStream; + stderr: ReadableStream; + kill(): Promise; + sendMessage(msg: string): Promise; +} +``` + +### 3. Что НЕ стоит делать + +- **Не использовать node-pty напрямую** (как Dorothy) — нет абстракции, сложно масштабировать +- **Не строить на Rust** (как vibe-kanban) — у нас TypeScript стек, overhead не оправдан +- **Не копировать multi-process daemon** (как Superset) — over-engineering для нашего случая +- **Не привязываться к одному протоколу** — у каждого CLI свой формат (stream-json, --json, --stream-json) + +### 4. Приоритет агентов для поддержки + +| Приоритет | Агент | Протокол | Сложность | +|-----------|-------|----------|-----------| +| P0 | Claude Code | stream-json bidirectional | Уже есть | +| P1 | Codex | `exec --json` JSONL | Средняя | +| P1 | Gemini CLI | `--output-format stream-json` | Средняя | +| P2 | Amp | `--execute --stream-json` | Средняя (CC-compatible) | +| P2 | Goose | `run -t` + `--output-format json` | Средняя | +| P3 | OpenCode | TBD | Исследовать | +| P3 | Cursor Agent | TBD | Исследовать | + +--- + +## Источники + +- [Vibe Kanban (BloopAI)](https://github.com/BloopAI/vibe-kanban) — Apache-2.0, 23K stars +- [Vibe Kanban AGENTS.md](https://github.com/BloopAI/vibe-kanban/blob/main/AGENTS.md) +- [Vibe Kanban executors crate](https://github.com/BloopAI/vibe-kanban/tree/main/crates/executors/src/executors) +- [Vibe Kanban PR #1759 — Qoder executor pattern](https://github.com/BloopAI/vibe-kanban/pull/1759) +- [Emdash (General Action)](https://github.com/generalaction/emdash) — MIT, 6K stars +- [Emdash Providers Documentation](https://docs.emdash.sh/providers) +- [Emdash AGENTS.md](https://github.com/generalaction/emdash/blob/main/AGENTS.md) +- [Dorothy (Charlie85270)](https://github.com/Charlie85270/Dorothy) — MIT, 3K stars +- [Superset (superset-sh)](https://github.com/superset-sh/superset) — ELv2, 7.8K stars +- [Superset DeepWiki Architecture](https://deepwiki.com/superset-sh/superset/1.1-architecture-overview) +- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) +- [Codex Non-Interactive Mode](https://developers.openai.com/codex/noninteractive) +- [Codex JSON output issues](https://github.com/openai/codex/issues/2288) +- [Gemini CLI Headless Mode](https://google-gemini.github.io/gemini-cli/docs/cli/headless.html) +- [Gemini CLI JSON issues](https://github.com/google-gemini/gemini-cli/issues/9009) +- [Amp Streaming JSON](https://ampcode.com/news/streaming-json) +- [Amp CLI Manual](https://ampcode.com/manual) +- [Goose CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) +- [Goose JSON output request #4419](https://github.com/block/goose/issues/4419) +- [Claude Code stream-json docs gap #24594](https://github.com/anthropics/claude-code/issues/24594) +- [Claude Code Automation Skill (LobeHub)](https://lobehub.com/it/skills/coreyja-dotfiles-claude-code-automation) diff --git a/docs/research/inter-agent-communication-standards.md b/docs/research/inter-agent-communication-standards.md new file mode 100644 index 00000000..f47ae5f7 --- /dev/null +++ b/docs/research/inter-agent-communication-standards.md @@ -0,0 +1,639 @@ +# Inter-Agent Communication Standards: How Different AI Agents Can Talk to Each Other + +**Date:** 2026-03-25 +**Status:** Research complete +**Goal:** Determine the best way for AI agents (Claude, Codex, Gemini) to communicate with each other + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Protocol Landscape Overview](#protocol-landscape-overview) +3. [A2A (Agent-to-Agent Protocol)](#1-a2a--agent-to-agent-protocol) +4. [ACP (Agent Communication Protocol) by IBM/BeeAI](#2-acp--agent-communication-protocol-by-ibmbeeai) +5. [Agent Client Protocol (ACP) by Zed](#3-agent-client-protocol-acp-by-zed) +6. [MCP for Inter-Agent Communication](#4-mcp-for-inter-agent-communication) +7. [Agent Network Protocol (ANP)](#5-agent-network-protocol-anp) +8. [MCP Agent Mail](#6-mcp-agent-mail) +9. [File-Based Inbox Pattern](#7-file-based-inbox-pattern-claude-code-agent-teams) +10. [SQLite/Redis Message Bus](#8-sqliteredis-message-bus) +11. [Cross-Provider Orchestration Tools](#9-cross-provider-orchestration-tools) +12. [Comparison Matrix](#comparison-matrix) +13. [Recommendations for Electron App](#recommendations-for-our-electron-app) +14. [Sources](#sources) + +--- + +## Executive Summary + +На март 2026 года НЕ существует единого универсального стандарта для межагентной коммуникации между разными провайдерами (Claude, Codex, Gemini). Однако экосистема быстро консолидируется вокруг нескольких протоколов: + +| Уровень | Протокол | Назначение | +|---------|----------|------------| +| Tool access | **MCP** (Anthropic) | Агент <-> инструменты/данные | +| Agent-to-Agent | **A2A** (Google/Linux Foundation) | Агент <-> агент (сетевой) | +| Editor-to-Agent | **ACP** (Zed) | Редактор <-> CLI-агент | +| Local coordination | **File-based inbox** (Claude Code) | Агент <-> агент (локальный) | +| Local coordination | **MCP Agent Mail** | Агент <-> агент (MCP + SQLite + Git) | + +**Ключевые выводы:** + +1. **A2A** -- самый зрелый протокол для agent-to-agent, но он HTTP/server-based и плохо подходит для чисто локального Electron-приложения без встроенного сервера. +2. **File-based inbox** (как в Claude Code Agent Teams) -- самый простой и проверенный паттерн для локальной коммуникации. Работает в Electron без проблем. +3. **MCP Agent Mail** -- наиболее feature-rich локальное решение (идентичности, mailboxes, file leases, searchable threads), но Python-based. +4. **MCP** сам по себе эволюционирует в сторону inter-agent communication (AWS, Microsoft активно контрибьютят). +5. **OpenCode** -- единственный инструмент, который реально запускает Claude + Codex + Gemini в одной команде через unified inbox pattern. + +--- + +## Protocol Landscape Overview + +``` + ┌─────────────────────────────────────┐ + │ Agent Network Protocol │ + │ (ANP - open internet, P2P, DID) │ + └──────────────────┬──────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ Agent-to-Agent Protocol (A2A) │ + │ (Google/LF, HTTP, JSON-RPC, tasks) │ + └──────────────────┬──────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + │ │ │ +┌─────────┴─────────┐ ┌─────────────┴─────────────┐ ┌─────────┴─────────┐ +│ MCP (Anthropic) │ │ Agent Client Protocol │ │ File-based inbox │ +│ Agent <-> Tools │ │ (Zed, editor <-> agent) │ │ (Claude Code local)│ +└───────────────────┘ └───────────────────────────┘ └───────────────────┘ +``` + +--- + +## 1. A2A -- Agent-to-Agent Protocol + +**Создатель:** Google, теперь под Linux Foundation +**Статус:** v0.3.0 (Draft v1.0), 150+ организаций-участников +**GitHub:** [a2aproject/A2A](https://github.com/a2aproject/A2A) -- 500+ stars (JS SDK) + +### Как работает + +1. Каждый агент публикует **Agent Card** (JSON) по адресу `/.well-known/agent.json` -- имя, навыки, endpoint, auth +2. Клиент-агент отправляет **задачу** серверу-агенту через JSON-RPC 2.0 over HTTPS +3. Задача проходит жизненный цикл: `submitted` -> `working` -> `completed`/`canceled` +4. Поддерживается streaming через SSE (Server-Sent Events) +5. Результат задачи -- **артефакт** (текст, изображения, файлы) + +### TypeScript SDK + +```bash +npm install @a2a-js/sdk +# Для Express-интеграции: +npm install express +``` + +Пакет: [`@a2a-js/sdk`](https://www.npmjs.com/package/@a2a-js/sdk) v0.3.10 +- 88 зависимых проектов на npm +- Поддержка Express, gRPC, in-memory task store +- Полные типы TypeScript + +**Минимальный сервер (Express):** +```typescript +import { AgentCard, AgentExecutor, DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk'; +import { agentCardHandler, jsonRpcHandler } from '@a2a-js/sdk/server/express'; +import express from 'express'; + +const card: AgentCard = { + name: 'MyAgent', + description: 'Example agent', + protocolVersion: '0.3.0', + url: 'http://localhost:4000/a2a/jsonrpc', + skills: [{ id: 'hello', name: 'Hello', description: 'Says hello' }], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], +}; + +class MyExecutor implements AgentExecutor { + async execute(context) { + context.eventBus.publish({ type: 'message', message: { role: 'agent', parts: [{ type: 'text', text: 'Hello!' }] } }); + context.eventBus.finished(); + } +} + +const handler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new MyExecutor()); +const app = express(); +app.get('/a2a/agent-card', agentCardHandler(handler)); +app.post('/a2a/jsonrpc', jsonRpcHandler(handler)); +app.listen(4000); +``` + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость протокола | 8/10 -- v0.3, Linux Foundation, 150+ организаций | +| TypeScript поддержка | 9/10 -- официальный SDK, полные типы | +| Electron-совместимость | 5/10 -- требует HTTP-сервер, придётся встраивать Express в main process | +| Локальная работа | 4/10 -- спроектирован для сетевого взаимодействия, localhost возможен но overhead | +| Кросс-провайдер | 9/10 -- протокол-агностик по дизайну | + +### Вердикт + +A2A -- правильный протокол для **распределённых сетевых** мультиагентных систем. Для локального Electron-приложения это overkill, но если планируется поддержка **удалённых агентов** в будущем -- имеет смысл держать в архитектуре. + +--- + +## 2. ACP -- Agent Communication Protocol by IBM/BeeAI + +**Создатель:** IBM Research / BeeAI +**Статус:** MERGED WITH A2A под Linux Foundation. Активная разработка свёрнута. +**GitHub:** [i-am-bee/acp](https://github.com/i-am-bee/acp) + +### Как работает + +- REST-native (не JSON-RPC как A2A) -- стандартные HTTP-конвенции +- Не требует SDK -- можно использовать через curl/Postman +- Async по умолчанию (fire-and-forget с taskId, poll/subscribe для прогресса) +- Sync также поддерживается (простой HTTP POST) +- Offline discovery -- метаданные агента встроены в пакет распространения + +### TypeScript SDK + +```bash +npm install @anthropic-ai/beeai-framework # TypeScript starter +``` + +BeeAI Framework предоставляет TypeScript-клиент для ACP. + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 3/10 -- merged into A2A, активная разработка прекращена | +| TypeScript | 6/10 -- клиентский SDK есть | +| Electron | 5/10 -- REST-based, аналогично A2A | +| Рекомендация | НЕ использовать, мигрировать на A2A | + +### Вердикт + +**Устаревший.** Объединён с A2A. Использовать только если уже есть код на ACP -- в таком случае мигрировать на A2A. + +--- + +## 3. Agent Client Protocol (ACP) by Zed + +**ВНИМАНИЕ:** Это ДРУГОЙ протокол с тем же акронимом ACP. Не путать с IBM ACP. + +**Создатель:** Zed Industries +**Статус:** Активный, ACP Registry запущен (2026) +**GitHub:** [agentclientprotocol/agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) +**Сайт:** [agentclientprotocol.com](https://agentclientprotocol.com/) + +### Как работает + +- Стандартизирует связь **редактор <-> CLI-агент** (аналогично LSP для языковых серверов) +- JSON-RPC over stdio (локальные агенты как subprocess) +- JSON-RPC over HTTP/WebSocket (удалённые агенты) +- Переиспользует JSON-представления из MCP где возможно +- Добавляет UX-специфичные типы (diff display, file edits) + +### Поддерживаемые агенты и редакторы + +**Агенты:** +- Claude Code (через Zed SDK adapter) +- Codex CLI +- Gemini CLI (reference implementation) +- OpenCode +- Goose (Block/Square) +- GitHub Copilot CLI +- Kiro CLI + +**Редакторы:** +- Zed (нативная поддержка) +- JetBrains IDEs (скоро) +- Neovim (CodeCompanion, avante.nvim) +- Emacs (agent-shell) +- VS Code (расширение ACP Client) + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 7/10 -- активный, registry, множество интеграций | +| TypeScript | 7/10 -- JSON-RPC, спецификация есть | +| Electron | 8/10 -- stdio-based отлично работает с child_process | +| Назначение | Editor <-> Agent, НЕ agent <-> agent | + +### Вердикт + +ACP (Zed) -- **идеален для связи нашего Electron UI с CLI-агентами**. Но это протокол editor<->agent, не agent<->agent. Для межагентной коммуникации нужен другой протокол поверх. + +--- + +## 4. MCP для Inter-Agent Communication + +**Создатель:** Anthropic +**Статус:** Активно развивается в сторону agent-to-agent (2026 roadmap) + +### Как это работает для inter-agent + +MCP изначально создан для tool integration, но его архитектура позволяет agent-to-agent: + +1. **Агент A запускает MCP-сервер**, объявляя свои capabilities как tools +2. **Агент B подключается как MCP-клиент** и вызывает tools агента A +3. Streaming через SSE для real-time обновлений +4. Session resumability для долгих задач +5. Multi-turn interactions через elicitation + +### Паттерн "Agent as MCP Server" + +``` +Agent A (MCP Client) ──────► Agent B (MCP Server) + │ │ + │ call tool "analyze" │ + ├─────────────────────────►│ + │ │ runs analysis + │ streaming results │ + │◄─────────────────────────┤ +``` + +### Кто продвигает + +- **AWS** активно контрибьютит в inter-agent MCP, работает с LangGraph, CrewAI, LlamaIndex +- **Microsoft** показала, что A2A-коммуникацию можно построить на MCP +- **Block (Square)** -- 1000+ инженеров используют MCP-координацию (Goose) + +### TypeScript SDK + +```bash +npm install @modelcontextprotocol/sdk zod +``` + +Официальный SDK: [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость для inter-agent | 5/10 -- изначально не для этого, но быстро эволюционирует | +| TypeScript | 10/10 -- официальный SDK, отличная поддержка | +| Electron | 9/10 -- stdio transport, уже используется в нашем приложении | +| Кросс-провайдер | 8/10 -- все провайдеры поддерживают MCP | + +### Вердикт + +MCP -- **наиболее практичный выбор** для нашего Electron-приложения. Мы уже используем MCP. Паттерн "agent as MCP server" позволяет любому агенту объявить tools/resources, а другой агент подключается как клиент. Roadmap 2026 явно включает agent-to-agent capabilities. + +--- + +## 5. Agent Network Protocol (ANP) + +**Создатель:** Open-source community +**Статус:** Draft, white paper на arXiv +**GitHub:** [agent-network-protocol/AgentNetworkProtocol](https://github.com/agent-network-protocol/AgentNetworkProtocol) +**Сайт:** [agent-network-protocol.com](https://agent-network-protocol.com/) + +### Как работает + +Трёхуровневая архитектура: +1. **Identity & Encrypted Communication** -- W3C DID (Decentralized Identifiers), end-to-end encryption +2. **Meta-Protocol Layer** -- агенты САМИ договариваются о протоколе коммуникации +3. **Application Protocol** -- JSON-LD для описания capabilities + +Позиционирование: "HTTP для агентного интернета". Peer-to-peer, без центральных серверов. + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 2/10 -- draft, ранняя стадия | +| TypeScript | 2/10 -- нет SDK | +| Electron | 3/10 -- P2P, сложная интеграция | +| Рекомендация | Следить, НЕ использовать сейчас | + +### Вердикт + +ANP -- интересная vision для **открытого агентного интернета** (peer-to-peer discovery, DID), но слишком рано для продакшна. Может стать актуален через 1-2 года. + +--- + +## 6. MCP Agent Mail + +**Создатель:** Jeff Emanuel (Dicklesworthstone) +**Статус:** Активный, первый open-source agent coordination tool (октябрь 2025) +**GitHub:** [Dicklesworthstone/mcp_agent_mail](https://github.com/Dicklesworthstone/mcp_agent_mail) +**Rust version:** [Dicklesworthstone/mcp_agent_mail_rust](https://github.com/Dicklesworthstone/mcp_agent_mail_rust) +**Сайт:** [mcpagentmail.com](https://mcpagentmail.com/) + +### Как работает + +- MCP-сервер, предоставляющий **34 tool** для координации агентов +- Каждый агент получает **идентичность** (memorable name: GreenCastle, BlueLake) +- **Inbox/Outbox** -- асинхронные mailbox для сообщений +- **Advisory File Reservations** -- агенты объявляют file leases (exclusive/shared) на globs +- **Searchable threads** -- FTS через SQLite +- **Git backing** -- все сообщения и артефакты в Git для аудита +- SQLite для индексации, Git как source of truth + +### Cross-Provider Support + +Работает с Claude Code, Codex, Gemini CLI, Factory Droid -- любой MCP-совместимый клиент. + +### Технические детали + +- Python-based сервер (FastMCP) +- Rust-реимплементация доступна (127.0.0.1:8765, TUI console) +- Не npm-пакет, установка через bash-скрипт +- Local-first, no cloud dependencies + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 7/10 -- production-used, хорошо документирован | +| TypeScript | 3/10 -- Python/Rust server, TS клиент через MCP SDK | +| Electron | 6/10 -- можно запустить как sidecar process, но Python/Rust зависимость | +| Feature-richness | 9/10 -- identities, mailboxes, file leases, FTS, Git audit | + +### Вердикт + +Самое feature-rich решение для координации агентов. **Проблема**: Python/Rust dependency. Для нашего Electron-приложения можно: +- Запустить как sidecar process +- Или реализовать ключевые идеи (mailbox, file leases) на TypeScript нативно + +--- + +## 7. File-Based Inbox Pattern (Claude Code Agent Teams) + +**Создатель:** Anthropic (Claude Code) +**Статус:** Production, Claude Code v2.1.32+ + +### Как работает + +Наиболее простой и проверенный паттерн: + +``` +~/.claude/teams/{team-name}/ +├── config.json # member registry +└── inboxes/ + ├── lead.json # lead's inbox + ├── frontend-dev.json # teammate inbox + └── backend-dev.json # teammate inbox +``` + +1. Отправитель **appends** JSON-объект в inbox-файл получателя +2. Получатель **polls** свой inbox-файл между turns +3. Формат сообщения: `{ from, text, timestamp, read }` +4. Broadcast = запись одного сообщения во ВСЕ inbox-файлы + +### Особенности + +- Zero dependencies -- только fs +- Inspectable -- `cat` любой inbox файл в реальном времени +- File I/O масштабируется для 3-5 агентов +- Нет real-time delivery -- получатель увидит сообщение только после текущего turn +- Ownership: каждый агент читает только СВОЙ inbox + +### Inbox/Outbox Pattern (улучшенный) + +``` +agent-a/ +├── inbox.json # входящие сообщения +├── outbox.json # исходящие (для аудита) +└── current-task.json + +agent-b/ +├── inbox.json +├── outbox.json +└── current-task.json +``` + +Правила координации: +- Агент пишет ТОЛЬКО в свой outbox и чужие inbox +- Агент читает ТОЛЬКО свой inbox и current-task +- Boot Sequence: при старте читать inbox.json, resume из current-task.json + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 9/10 -- production в Claude Code | +| TypeScript | 10/10 -- чистый fs/path, тривиальная реализация | +| Electron | 10/10 -- идеально, никаких зависимостей | +| Масштабируемость | 5/10 -- до ~10 агентов, потом I/O bottleneck | +| Feature-richness | 4/10 -- только messaging, нет identities/leases/FTS | + +### Вердикт + +**Лучший выбор для немедленного использования.** Мы УЖЕ используем этот паттерн в нашем приложении. Для межагентной коммуникации между разными провайдерами -- это самый простой путь: агенты любого провайдера пишут/читают JSON-файлы. + +--- + +## 8. SQLite/Redis Message Bus + +### SQLite Message Bus + +Паттерн из сообщества: Flask + SQLite message bus для ~16 агентов. + +**Особенности:** +- HTTP API для отправки/получения сообщений +- Broadcast messaging (omit "to" field) +- Reply chains через `reply_to` +- Priority levels (normal/high/urgent) +- Read receipts +- `journal_mode=WAL` для конкурентного доступа +- Auto-archiving старых сообщений + +### Redis Approaches + +| Подход | Плюсы | Минусы | +|--------|-------|--------| +| Redis Pub/Sub | Real-time, low latency | Ephemeral -- сообщения теряются | +| Redis Streams | Persistent, consumer groups | Требует Redis server | +| redis-bus | Autodiscovery, cache | Legacy (Python 2.7) | + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| SQLite bus | 7/10 -- хорошо для Electron (better-sqlite3 уже есть) | +| Redis | 3/10 -- требует отдельный server, overkill для desktop | +| TypeScript | 8/10 (SQLite) / 6/10 (Redis) | +| Масштабируемость | 8/10 (SQLite WAL) / 9/10 (Redis) | + +### Вердикт + +**SQLite message bus** -- отличный апгрейд с file-based inbox, если нужна persistence, FTS, priority, read receipts. `better-sqlite3` уже хорошо работает в Electron. Redis -- overkill для локального desktop-приложения. + +--- + +## 9. Cross-Provider Orchestration Tools + +### OpenCode -- True Multi-Model Agent Teams + +OpenCode -- единственный инструмент, который **реально запускает Claude + Codex + Gemini в одной команде**. + +**Архитектура:** +- Event-driven inbox (не polling как Claude Code) +- Per-agent JSONL файлы: `{ id, from, text, timestamp, read }` +- Session injection для delivery (не file polling) +- Shared task list с claiming + +**Отличия от Claude Code:** +- Multi-model support (Claude, GPT, Gemini в одной команде) +- Peer-to-peer messaging (не только через lead) +- Event-driven (не polling) +- Append-only JSONL (не JSON array) +- Всё in-process (locks в памяти) + +### sub-agents-skills + +GitHub: [shinpr/sub-agents-skills](https://github.com/shinpr/sub-agents-skills) + +Позволяет использовать Codex, Claude Code, Gemini CLI как sub-agents из любого parent session. Cross-LLM делегация задач. + +### ZenFlow (Zencoder) + +Structured handoffs между Claude и Gemini с quality gates. Не open-source. + +### CC Switch + +Unified management: proxy Claude/Codex/Gemini, unified MCP panel, markdown editor с cross-app sync для CLAUDE.md/AGENTS.md/GEMINI.md. + +--- + +## Comparison Matrix + +| | A2A | MCP (inter-agent) | ACP (Zed) | File Inbox | MCP Agent Mail | SQLite Bus | +|---|---|---|---|---|---|---| +| **Зрелость** | 8/10 | 5/10 | 7/10 | 9/10 | 7/10 | 6/10 | +| **TS SDK** | 9/10 | 10/10 | 7/10 | 10/10 | 3/10 | 8/10 | +| **Electron-ready** | 5/10 | 9/10 | 8/10 | 10/10 | 6/10 | 7/10 | +| **Cross-provider** | 9/10 | 8/10 | 9/10 | 10/10 | 9/10 | 10/10 | +| **No server needed** | No | Partial | Yes (stdio) | Yes | No | Yes | +| **Real-time** | Yes (SSE) | Yes (SSE) | Yes | No (polling) | No | Polling | +| **Persistence** | Optional | No | No | File-based | Git+SQLite | SQLite | +| **File coordination** | No | No | No | No | Yes (leases) | No | +| **Identity system** | Agent Cards | No | No | No | Yes | No | +| **Сложность** | High | Medium | Medium | Very Low | High | Low | + +--- + +## Recommendations for Our Electron App + +### Немедленно (Phase 1) -- File-Based Inbox + +**Надёжность: 9/10 | Уверенность: 10/10** + +Мы уже используем file-based inbox для Claude Code Agent Teams. Этот же паттерн работает для ЛЮБОГО CLI-агента (Codex, Gemini CLI). Агенту не нужно знать протокол -- он просто читает/пишет JSON-файлы. + +``` +~/.claude_teams/{team-name}/inboxes/ +├── claude-lead.json +├── codex-worker.json +├── gemini-researcher.json +``` + +**Что нужно для cross-provider:** +1. Unified inbox format (уже есть: `{ from, text, timestamp, read }`) +2. Agent spawner для каждого CLI (Claude Code, Codex CLI, Gemini CLI) +3. Каждый агент получает system prompt с инструкцией читать/писать inbox files +4. Task board (shared JSON files с flock) + +### Среднесрочно (Phase 2) -- SQLite Message Bus + +**Надёжность: 8/10 | Уверенность: 8/10** + +Upgrade с file-based на SQLite для: +- Persistence и searchable history +- Priority levels и read receipts +- Better concurrency (WAL mode) +- FTS для поиска по сообщениям + +`better-sqlite3` уже отлично работает в Electron. + +### Долгосрочно (Phase 3) -- MCP-Based Inter-Agent + +**Надёжность: 6/10 | Уверенность: 6/10** + +Когда MCP roadmap 2026 реализует agent-to-agent capabilities: +- Каждый агент запускает MCP-сервер со своими capabilities +- Другие агенты подключаются как MCP-клиенты +- Streaming, session management, tool negotiation из коробки +- @modelcontextprotocol/sdk уже в нашем стеке + +### Если потребуются удалённые агенты (Phase 4) -- A2A + +**Надёжность: 7/10 | Уверенность: 5/10** + +A2A имеет смысл только если: +- Нужна коммуникация с агентами на других машинах +- Интеграция с enterprise-системами (Salesforce, SAP агенты) +- Cloud-hosted агенты + +В этом случае: встроить Express-сервер в Electron main process, использовать @a2a-js/sdk. + +### Конкретный ответ на вопрос: "Как заставить Claude поговорить с Codex?" + +**Самый простой работающий способ прямо сейчас:** + +1. Spawn Claude Code CLI как child_process +2. Spawn Codex CLI как child_process +3. Оба читают/пишут в общую директорию inbox-файлов +4. System prompt для каждого включает инструкцию: "To communicate with other agents, write to their inbox file at {path}" +5. Наше Electron-приложение выступает оркестратором: следит за inbox-файлами, доставляет сообщения через stdin, обновляет UI + +Это РОВНО то, что делает Claude Code Agent Teams, и ровно то, что OpenCode расширил для multi-provider. + +--- + +## Sources + +### Протоколы и спецификации +- [A2A Protocol Official Site](https://a2a-protocol.org/latest/) +- [A2A GitHub Repository](https://github.com/a2aproject/A2A) +- [A2A JS SDK](https://github.com/a2aproject/a2a-js) -- [@a2a-js/sdk npm](https://www.npmjs.com/package/@a2a-js/sdk) +- [Agent Client Protocol (Zed)](https://agentclientprotocol.com/) -- [GitHub](https://github.com/agentclientprotocol/agent-client-protocol) +- [ACP Registry](https://zed.dev/blog/acp-registry) +- [Agent Communication Protocol (IBM/BeeAI)](https://github.com/i-am-bee/acp) -- [IBM Research](https://research.ibm.com/projects/agent-communication-protocol) +- [Agent Network Protocol](https://agent-network-protocol.com/) -- [GitHub](https://github.com/agent-network-protocol/AgentNetworkProtocol) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) + +### Анонсы и статьи +- [Google: Announcing A2A](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [Google Cloud: A2A Getting an Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Linux Foundation: A2A Project Launch](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents) +- [IBM: What is A2A?](https://www.ibm.com/think/topics/agent2agent-protocol) +- [IBM: What is ACP?](https://www.ibm.com/think/topics/agent-communication-protocol) +- [AWS: Inter-Agent Communication on MCP](https://aws.amazon.com/blogs/opensource/open-protocols-for-agent-interoperability-part-1-inter-agent-communication-on-mcp/) +- [Microsoft: A2A on MCP](https://developer.microsoft.com/blog/can-you-build-agent2agent-communication-on-mcp-yes) +- [Auth0: MCP vs A2A](https://auth0.com/blog/mcp-vs-a2a/) +- [Developer's Guide to AI Agent Protocols](https://developers.googleblog.com/developers-guide-to-ai-agent-protocols/) + +### Claude Code Agent Teams +- [Official Docs](https://code.claude.com/docs/en/agent-teams) +- [Reverse-Engineering Claude Code Agent Teams](https://dev.to/nwyin/reverse-engineering-claude-code-agent-teams-architecture-and-protocol-o49) +- [How They Work Under the Hood](https://www.claudecodecamp.com/p/claude-code-agent-teams-how-they-work-under-the-hood) + +### Cross-Provider Orchestration +- [OpenCode Agent Teams](https://dev.to/uenyioha/porting-claude-codes-agent-teams-to-opencode-4hol) +- [sub-agents-skills](https://github.com/shinpr/sub-agents-skills) +- [ZenFlow Multi-Agent Orchestration](https://docs.zencoder.ai/user-guides/guides/multi-agent-orchestration-in-zenflow) +- [Zed: External Agents](https://zed.dev/docs/ai/external-agents) + +### Agent Coordination +- [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) -- [Site](https://mcpagentmail.com/) +- [MCP Agent Mail Rust](https://github.com/Dicklesworthstone/mcp_agent_mail_rust) +- [Inbox/Outbox Pattern](https://earezki.com/ai-news/2026-03-09-the-inbox-outbox-pattern-how-ai-agents-coordinate-without-stepping-on-each-other/) +- [Multi-Agent Communication Patterns](https://dev.to/aureus_c_b3ba7f87cc34d74d49/multi-agent-communication-patterns-that-actually-work-50kp) +- [Agent Message Bus (SQLite)](https://dev.to/linou518/agent-message-bus-communication-infrastructure-for-16-ai-agents-18af) + +### Google ADK +- [ADK with A2A](https://google.github.io/adk-docs/a2a/) +- [ADK Docs](https://google.github.io/adk-docs/agents/models/google-gemini/) + +### Surveys +- [Survey of Agent Interoperability Protocols (arXiv)](https://arxiv.org/abs/2505.02279) +- [Top 5 Open Protocols for Multi-Agent AI Systems](https://onereach.ai/blog/power-of-multi-agent-ai-open-protocols/) +- [10 Modern AI Agent Protocols](https://www.ssonetwork.com/intelligent-automation/columns/ai-agent-protocols-10-modern-standards-shaping-the-agentic-era) diff --git a/docs/research/mastra-integration-analysis.md b/docs/research/mastra-integration-analysis.md new file mode 100644 index 00000000..4a4f2541 --- /dev/null +++ b/docs/research/mastra-integration-analysis.md @@ -0,0 +1,756 @@ +# Mastra Integration Analysis + +> Technical feasibility study for integrating Mastra (TypeScript agent framework) with Claude Agent Teams UI. +> Date: 2026-03-24 + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Mastra Architecture Overview](#mastra-architecture-overview) +3. [Our Codebase Architecture](#our-codebase-architecture) +4. [Integration Points Analysis](#integration-points-analysis) +5. [Concrete Integration Approaches](#concrete-integration-approaches) +6. [Architecture Diagram](#architecture-diagram) +7. [What Stays the Same](#what-stays-the-same) +8. [What Must Change](#what-must-change) +9. [Effort Estimate](#effort-estimate) +10. [Risks and Blockers](#risks-and-blockers) +11. [Recommendations](#recommendations) +12. [Sources](#sources) + +--- + +## Executive Summary + +Mastra is a TypeScript-first agent framework (22K+ stars, $13M seed, YC-backed) from the Gatsby team. It provides unified primitives for agents, tools, workflows, RAG, and multi-agent orchestration with 40+ LLM provider support. + +**Key finding: Mastra operates at a fundamentally different level than Claude CLI.** Our app is a process manager and UI for Claude Code CLI sessions. Mastra is an SDK for building agents programmatically. Integration is possible but requires a significant architectural shift — specifically, replacing Claude CLI process management with in-process Mastra agent runtime. + +**Verdict: 6/10 feasibility, 4/10 reliability of quick integration.** The integration is architecturally sound but represents 6-10 person-weeks of work with significant risk to our core differentiator (Claude Code CLI features: file editing, terminal, git, Agent tool for spawning teammates). + +--- + +## Mastra Architecture Overview + +### Core Packages + +| Package | Purpose | +|---------|---------| +| `@mastra/core` | Agent, Workflow, Tool, Server, Storage, Vector, DI | +| `@mastra/mcp` | MCPClient (consume) + MCPServer (expose) | +| `@mastra/ai-sdk` | AI SDK v5 compatibility layer | +| `@mastra/client-js` | HTTP client for remote Mastra servers | +| `mastra` | CLI for project scaffolding | + +### Agent Definition + +```typescript +import { Agent } from '@mastra/core/agent'; +import { MCPClient } from '@mastra/mcp'; + +const agent = new Agent({ + id: 'team-lead', + name: 'Team Lead', + instructions: 'You coordinate the team...', + model: 'anthropic/claude-sonnet-4-20250514', // any of 40+ providers + tools: { taskCreate, taskUpdate, sendMessage }, +}); + +// Usage +const result = await agent.generate('Create tasks for the frontend sprint'); +const stream = await agent.stream('Review the PR'); +``` + +### Multi-Agent: Supervisor Pattern (recommended as of Feb 2026) + +```typescript +const researcher = new Agent({ + id: 'researcher', + description: 'Researches technical topics', + model: 'anthropic/claude-sonnet-4-20250514', + tools: { webSearch, readFile }, +}); + +const developer = new Agent({ + id: 'developer', + description: 'Implements code changes', + model: 'anthropic/claude-sonnet-4-20250514', + tools: { editFile, runTests, bash }, +}); + +const supervisor = new Agent({ + id: 'supervisor', + name: 'Team Lead', + instructions: 'Coordinate researcher and developer...', + model: 'anthropic/claude-sonnet-4-20250514', + agents: { researcher, developer }, // auto-converted to tools + memory: new Memory(), +}); + +const stream = await supervisor.stream('Fix the authentication bug', { + maxSteps: 20, +}); +``` + +### MCP Integration + +```typescript +import { MCPClient } from '@mastra/mcp'; + +const mcp = new MCPClient({ + servers: { + 'agent-teams': { + command: 'node', + args: ['/path/to/mcp-server/dist/index.js'], + }, + }, +}); + +const agent = new Agent({ + id: 'worker', + model: 'anthropic/claude-sonnet-4-20250514', + instructions: '...', +}); + +// Dynamic tool injection +const response = await agent.stream('Update task #abc to in_progress', { + toolsets: await mcp.listToolsets(), +}); +``` + +--- + +## Our Codebase Architecture + +### Process Management Layer (Claude-specific) + +The core of our backend is `TeamProvisioningService` — an 8000+ line service that manages Claude CLI processes. + +**Key file**: `src/main/services/team/TeamProvisioningService.ts` + +Core flow: +1. **Resolve Claude binary** via `ClaudeBinaryResolver` +2. **Build provisioning prompt** (~100 lines of structured instructions) via `buildProvisioningPrompt()` +3. **Spawn CLI process** with stream-json protocol: + ``` + spawnCli(claudePath, [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--mcp-config', mcpConfigPath, + '--disallowedTools', 'TeamDelete,TodoWrite', + '--dangerously-skip-permissions', + ]) + ``` +4. **Parse stdout** as NDJSON (newline-delimited JSON) — types: `user`, `assistant`, `control_request`, `result`, `system` +5. **Send input via stdin** using stream-json protocol: `{"type":"user","message":{"role":"user","content":[...]}}\n` +6. **Monitor filesystem** for team config, tasks, inboxes written by CLI +7. **Relay messages** between lead and teammates via inbox files + +**Key file**: `src/main/utils/childProcess.ts` — `spawnCli()` and `execCli()` wrappers with Windows shell fallback and EINVAL handling. + +### Prompt/Instruction System + +The prompt system is deeply intertwined with Claude Code's native capabilities: + +**`buildProvisioningPrompt()`** (line ~860) constructs a multi-section prompt: +- Team identity (name, project, lead) +- Step 1: Call **BUILT-IN TeamCreate tool** (Claude Code native) +- Step 2: Spawn teammates via **Agent tool** (Claude Code native) with `team_name` parameter +- Step 3: Create tasks via **MCP board tools** +- Persistent lead context: communication protocol, board MCP operations, agent block policy + +**`buildMemberSpawnPrompt()`** (line ~444) constructs per-teammate instructions: +- Role and workflow injection +- `member_briefing` MCP bootstrap call +- Task lifecycle protocol (comment -> start -> work -> comment -> complete) +- Detailed notification/escalation rules + +**Critical Claude-specific constructs in prompts:** +- `Agent` tool with `team_name` parameter — Claude Code's native teammate spawning +- `TeamCreate` built-in tool — Claude Code's team lifecycle management +- `SendMessage` built-in tool — Claude Code's inter-agent messaging +- `--disallowedTools TeamDelete,TodoWrite` — Claude Code CLI flags +- `--permission-mode bypassPermissions` — Claude Code permission system +- stream-json protocol for bidirectional communication +- Post-compact context reinjection for context window management + +### MCP Server + +**Key files**: `mcp-server/src/` (16 TypeScript files) + +Our MCP server (FastMCP-based) exposes domain tools to agents: + +| Tool Domain | Tools | File | +|------------|-------|------| +| Tasks | task_create, task_get, task_list, task_start, task_complete, etc. | `taskTools.ts` | +| Kanban | kanban_get, kanban_set_column, kanban_clear | `kanbanTools.ts` | +| Review | review_request, review_approve, review_request_changes | `reviewTools.ts` | +| Messages | send messages between agents | `messageTools.ts` | +| Process | process management | `processTools.ts` | +| Cross-team | cross_team_send, cross_team_list_targets | `crossTeamTools.ts` | +| Runtime | runtime state queries | `runtimeTools.ts` | + +All tools delegate to `agent-teams-controller` — a workspace package that manages team state (config.json, tasks/, inboxes/). + +### Message Parsing Pipeline + +**Key files**: +- `src/main/types/jsonl.ts` — Raw JSONL format types (Claude Code session files) +- `src/main/types/messages.ts` — ParsedMessage with type guards +- `src/main/services/analysis/ChunkBuilder.ts` — Builds timeline chunks from parsed messages + +The JSONL parsing is tightly coupled to Claude Code's output format: +- Entry types: `user`, `assistant`, `system`, `summary`, `file-history-snapshot`, `queue-operation` +- Content blocks: `text`, `thinking`, `tool_use`, `tool_result`, `image` +- Usage metadata: `input_tokens`, `output_tokens`, `cache_read_input_tokens` +- Claude-specific fields: `model`, `stop_reason`, `cwd`, `gitBranch`, `agentId`, `isSidechain` + +### IPC Layer + +**Key file**: `src/main/ipc/teams.ts` — 60+ IPC channels for team operations + +The renderer communicates with main process via Electron IPC. The channels include team CRUD, task management, message sending, provisioning control, tool approval, and process lifecycle. + +--- + +## Integration Points Analysis + +### 1. Process Spawning — Claude CLI vs Mastra Agent Runtime + +| Aspect | Current (Claude CLI) | Mastra Integration | +|--------|---------------------|-------------------| +| **Runtime** | External process (`claude` binary) | In-process Node.js (`Agent.stream()`) | +| **Protocol** | stream-json over stdin/stdout | Programmatic TypeScript API | +| **Agent spawning** | `Agent` tool with `team_name` param | `new Agent({ agents: {...} })` supervisor pattern | +| **Tool execution** | Claude Code built-in + MCP | Mastra tools + `@mastra/mcp` MCPClient | +| **File editing** | Claude Code's built-in file tools | Must provide custom tools (Read, Write, Bash) | +| **Terminal** | Claude Code's built-in terminal | Must provide custom Bash tool | +| **Git** | Claude Code's built-in git support | Must provide custom git tools | +| **Context window** | Claude Code manages (200K) | Mastra manages via provider settings | + +**Claude-specificity score: 9/10** — This is the most tightly coupled area. + +### 2. Prompt/Instruction System + +| Aspect | Current | Mastra Equivalent | +|--------|---------|-------------------| +| System prompt | Injected via stream-json first message | `Agent.instructions` property | +| Dynamic instructions | Post-compact reinjection via stdin | `instructions` as function returning dynamic text | +| Built-in tools refs | `TeamCreate`, `Agent`, `SendMessage` in prompt | Must be replaced with Mastra tool calls | +| MCP tool refs | `task_create { teamName: "..." }` | Same MCP tools via `@mastra/mcp` MCPClient | + +**Claude-specificity score: 7/10** — Prompts reference Claude Code native tools extensively. + +### 3. MCP Server + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Server framework | FastMCP (stdio transport) | Same — OR convert to Mastra tools directly | +| Tool definitions | `server.addTool({ name, parameters, execute })` | `createTool({ id, inputSchema, execute })` | +| Transport | stdio (spawned by Claude CLI) | Could use `@mastra/mcp` MCPClient or convert to native Mastra tools | +| Controller | `agent-teams-controller` package | **Unchanged** — pure JS, no Claude dependency | + +**Claude-specificity score: 2/10** — MCP is provider-agnostic. Our `agent-teams-controller` is pure business logic. + +### 4. Message Parsing / JSONL Pipeline + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Session storage | `~/.claude/projects/{path}/*.jsonl` | Mastra has its own storage/memory system | +| Format | Claude Code JSONL (specific schema) | Mastra streaming chunks (text-delta, tool-call, etc.) | +| Type guards | `isParsedRealUserMessage`, etc. | New type guards for Mastra output format | +| Chunk building | `ChunkBuilder` from JSONL messages | New adapter from Mastra stream events | +| Subagent detection | `SubagentResolver` from tool_use content | Mastra supervisor tracks sub-agent calls natively | + +**Claude-specificity score: 8/10** — The entire analysis pipeline assumes Claude Code JSONL format. + +### 5. Team Lifecycle (config, inboxes, tasks) + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Team config | `~/.claude/teams/{name}/config.json` (Claude CLI creates) | Must be managed by our app directly | +| Task storage | `~/.claude/tasks/{name}/` (agent-teams-controller) | **Unchanged** | +| Inbox messaging | `~/.claude/teams/{name}/inboxes/{member}.json` | Replace with Mastra memory or direct tool calls | +| Cross-team comms | Inbox files with relay | Mastra agents can call each other directly | + +**Claude-specificity score: 6/10** — File-based coordination is Claude CLI convention, but our controller is independent. + +--- + +## Concrete Integration Approaches + +### Approach A: Mastra as Agent Runtime (Replace Claude CLI) + +**Confidence: 5/10 | Reliability: 4/10** + +Replace `spawnCli()` with in-process Mastra agents. The lead becomes a `supervisor` Agent, teammates become sub-agents. + +```typescript +// src/main/services/team/MastraTeamRuntime.ts (new file) +import { Agent } from '@mastra/core/agent'; +import { MCPClient } from '@mastra/mcp'; +import { createTool } from '@mastra/core/tools'; + +// Convert our MCP tools to native Mastra tools +const taskCreateTool = createTool({ + id: 'task_create', + description: 'Create a team task', + inputSchema: z.object({ + teamName: z.string(), + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + }), + execute: async (input) => { + const controller = getController(input.teamName); + return controller.tasks.createTask(input); + }, +}); + +// File editing tool (replaces Claude Code built-in) +const editFileTool = createTool({ + id: 'edit_file', + description: 'Edit a file on disk', + inputSchema: z.object({ + path: z.string(), + oldText: z.string(), + newText: z.string(), + }), + execute: async (input) => { + // Must implement file editing logic ourselves + const content = await fs.promises.readFile(input.path, 'utf8'); + const updated = content.replace(input.oldText, input.newText); + await fs.promises.writeFile(input.path, updated); + return { success: true }; + }, +}); + +// Bash tool (replaces Claude Code built-in) +const bashTool = createTool({ + id: 'bash', + description: 'Execute a bash command', + inputSchema: z.object({ command: z.string() }), + execute: async (input) => { + const { stdout, stderr } = await execAsync(input.command); + return { stdout, stderr }; + }, +}); + +// Create teammate agents +function createTeammateAgent(member: TeamMember, teamTools: Record) { + return new Agent({ + id: `teammate-${member.name}`, + name: member.name, + description: member.role || 'Team member', + instructions: buildMemberInstructions(member), // adapted from buildMemberSpawnPrompt + model: 'anthropic/claude-sonnet-4-20250514', + tools: { + ...teamTools, + editFileTool, + bashTool, + readFileTool, + // ... other dev tools + }, + }); +} + +// Create supervisor (lead) agent +function createLeadAgent( + request: TeamCreateRequest, + teammates: Record +) { + return new Agent({ + id: `lead-${request.teamName}`, + name: 'team-lead', + instructions: buildLeadInstructions(request), // adapted from buildPersistentLeadContext + model: request.model || 'anthropic/claude-sonnet-4-20250514', + agents: teammates, // Mastra auto-converts to tools + tools: { + ...teamTools, // task_create, kanban_get, etc. + editFileTool, + bashTool, + readFileTool, + }, + memory: new Memory(), + }); +} +``` + +**What breaks:** +- Claude Code's file editing (diff view, permission system) — must reimplement +- Claude Code's terminal integration +- Claude Code's git support +- Claude Code's extended thinking +- Claude Code's session persistence/resume +- The entire JSONL parsing pipeline +- Tool approval flow (our `control_request` handling) +- Post-compact context reinjection + +### Approach B: Mastra as Middleware / Orchestration Layer (Keep Claude CLI) + +**Confidence: 7/10 | Reliability: 6/10** + +Use Mastra as an orchestration layer that manages routing and coordination, while still spawning Claude CLI processes for actual work. + +```typescript +// src/main/services/team/MastraOrchestrator.ts (new file) +import { Agent } from '@mastra/core/agent'; +import { createTool } from '@mastra/core/tools'; + +// Tool that spawns a Claude CLI process for actual work +const claudeCliTool = createTool({ + id: 'claude_cli_execute', + description: 'Execute a task using Claude Code CLI', + inputSchema: z.object({ + prompt: z.string(), + cwd: z.string(), + model: z.string().optional(), + }), + execute: async (input) => { + // Spawn Claude CLI with -p (one-shot) + const result = await execCli(claudePath, [ + '-p', input.prompt, + '--output-format', 'text', + ...(input.model ? ['--model', input.model] : []), + ], { cwd: input.cwd }); + return { output: result.stdout }; + }, +}); + +// Mastra agent for high-level orchestration +const orchestrator = new Agent({ + id: 'orchestrator', + name: 'Task Orchestrator', + instructions: `You coordinate a development team. + Use claude_cli_execute for actual coding tasks. + Use task tools for board management.`, + model: 'anthropic/claude-sonnet-4-20250514', + tools: { + claudeCliTool, + ...teamBoardTools, + }, +}); + +// Orchestrator decides what to do, Claude CLI does the coding +const stream = await orchestrator.stream(userMessage); +``` + +**What this preserves:** +- Claude CLI's file editing, terminal, git, etc. +- Our existing JSONL pipeline (for CLI-executed tasks) +- MCP server tools (used by CLI processes) + +**What this adds:** +- Model-agnostic orchestration layer +- Ability to use OpenAI/Gemini/etc. for routing decisions +- Mastra's workflow engine for deterministic task flows + +**What breaks / gets complex:** +- Two runtime models (Mastra in-process + Claude CLI processes) +- Doubled complexity for message flow +- Unclear who "owns" the conversation state + +### Approach C: Mastra MCP Bridge (Minimal Integration) + +**Confidence: 8/10 | Reliability: 7/10** + +Use `@mastra/mcp` MCPServer to expose our existing tools to any Mastra-compatible client, and `@mastra/mcp` MCPClient to consume external MCP tools. + +```typescript +// mcp-server/src/mastra-bridge.ts (new file) +import { MCPServer } from '@mastra/mcp'; +import { Agent } from '@mastra/core/agent'; +import { registerTools } from './tools'; + +// Expose our existing tools as an MCP server that Mastra agents can consume +const mcpServer = new MCPServer({ + name: 'agent-teams-mcp', + version: '1.0.0', + tools: { + // Convert FastMCP tools to Mastra tools, or expose via MCP protocol + ...convertFastMcpToMastraTools(registerTools), + }, +}); + +// Any Mastra agent can now use our board tools +const externalAgent = new Agent({ + id: 'external-worker', + model: 'openai/gpt-4o', + instructions: 'You manage tasks on the team board.', + tools: await new MCPClient({ + servers: { + 'agent-teams': { + command: 'node', + args: ['path/to/mcp-server/dist/index.js'], + }, + }, + }).listTools(), +}); +``` + +**What this preserves:** +- Everything — this is additive, not replacement +- Claude CLI remains the primary runtime + +**What this adds:** +- Mastra agents can interact with our board +- Path to multi-provider support +- Future extensibility + +--- + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Electron App (Renderer) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ Kanban │ │ Timeline │ │ Inbox │ │ Code Editor │ │ +│ │ Board │ │ View │ │ Chat │ │ (Diff View) │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───────┬───────┘ │ +│ └──────────────┴─────────────┴────────────────┘ │ +│ │ IPC │ +└──────────────────────────────┼───────────────────────────────────┘ + │ +┌──────────────────────────────┼───────────────────────────────────┐ +│ Electron App (Main) │ +│ │ │ +│ ┌───────────────────────────┴──────────────────────────────┐ │ +│ │ IPC Handler Layer (teams.ts) │ │ +│ └───────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ +│ MASTRA MIDDLEWARE LAYER (Approach B / Future) │ +│ │ ┌─────────────────┐ │ ┌─────────────────────┐ │ │ +│ │ Mastra Agent │ │ │ Mastra Workflow │ │ +│ │ │ (Orchestrator) │ │ │ (Task Routing) │ │ │ +│ │ model-agnostic │ │ │ DAG execution │ │ +│ │ └────────┬─────────┘ │ └──────────┬──────────┘ │ │ +│ │ │ │ │ +│ └ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ┼─ ─ ─ ─ ─ ─ ─ ┘ │ +│ │ │ │ │ +│ ┌───────────┴──────────────┴───────────────┴──────────────┐ │ +│ │ TeamProvisioningService (existing) │ │ +│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ +│ │ │ spawnCli() │ │ stream-json │ │ FS monitor │ │ │ +│ │ │ (Claude CLI)│ │ parser │ │ (tasks/inbox) │ │ │ +│ │ └──────┬──────┘ └──────┬───────┘ └───────┬───────┘ │ │ +│ └─────────┼────────────────┼───────────────────┼──────────┘ │ +│ │ │ │ │ +│ ┌─────────┴────────────────┴───────────────────┴──────────┐ │ +│ │ agent-teams-controller (pure JS) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ +│ │ │ Tasks │ │ Kanban │ │ Inbox │ │ Config │ │ │ +│ │ │ CRUD │ │ State │ │ Messages │ │ Reader │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────┼───────────────────────────────┐ │ +│ │ MCP Server (agent-teams-mcp) │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────────┐ │ │ +│ │ │ Tasks │ │ Kanban │ │ Review │ │ Messages │ │ │ +│ │ │ Tools │ │ Tools │ │ Tools │ │ & Cross-team │ │ │ +│ │ └────────┘ └────────┘ └────────┘ └────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + │ Claude CLI Process │ ← or Mastra agent.stream() + │ (stream-json) │ + │ ┌────────────────┐ │ + │ │ File Edit │ │ + │ │ Terminal/Bash │ │ + │ │ Git │ │ + │ │ Agent (spawn) │ │ + │ │ SendMessage │ │ + │ │ MCP tools │ │ + │ └────────────────┘ │ + └─────────────────────┘ +``` + +--- + +## What Stays the Same + +These modules are **not Claude-specific** and would survive any integration: + +| Module | Path | Why | +|--------|------|-----| +| `agent-teams-controller` | `agent-teams-controller/` | Pure JS business logic for tasks, kanban, review, inbox. Zero Claude dependency. | +| MCP Server tools | `mcp-server/src/tools/*.ts` | Standard MCP protocol. Works with any MCP-compatible agent. | +| UI components | `src/renderer/` | React/Zustand/Tailwind. Communicates via IPC, agnostic to backend. | +| IPC layer interface | `src/preload/constants/ipcChannels.ts` | Channel names are just strings. | +| Shared types | `src/shared/types/team.ts` | TeamTask, InboxMessage, etc. — domain types. | +| Team data services | `TeamDataService`, `TeamConfigReader`, `TeamTaskReader` | File-based, read team state from disk. | +| TeamMcpConfigBuilder | `src/main/services/team/TeamMcpConfigBuilder.ts` | Builds MCP config files. Could serve Mastra MCPClient too. | +| Notification system | `NotificationManager` | UI notifications, not Claude-specific. | + +--- + +## What Must Change + +### Tier 1: Core Runtime (Required for any Mastra integration) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `TeamProvisioningService.ts` | ~8000 | Major refactor: abstract `AgentRuntime` interface. spawnCli() becomes one implementation, Mastra becomes another. | +| `childProcess.ts` | 220 | Keep as-is for Claude CLI path. New `MastraRuntime.ts` for in-process agents. | +| `ClaudeBinaryResolver.ts` | ~200 | Keep for Claude CLI path. Not needed for Mastra path. | + +### Tier 2: Message Parsing (Required for Approach A) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `src/main/types/jsonl.ts` | 200+ | New parallel types for Mastra streaming events. | +| `src/main/types/messages.ts` | 377 | Extend ParsedMessage or create MastraMessage adapter. | +| `ChunkBuilder.ts` | ~600 | Abstract chunk building from JSONL parsing. Mastra adapter produces same chunk types. | +| `SubagentResolver.ts` | ~400 | Mastra supervisor natively tracks sub-agents. Simpler resolver. | +| `SemanticStepExtractor.ts` | ~300 | Mastra tool calls have different structure. Adapter needed. | + +### Tier 3: Prompt System (Required for all approaches) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `buildProvisioningPrompt()` | ~100 | Remove Claude-specific steps (TeamCreate, Agent tool). Replace with Mastra tool references. | +| `buildMemberSpawnPrompt()` | ~80 | Convert to Mastra Agent `instructions`. Remove Agent tool spawn references. | +| `buildPersistentLeadContext()` | ~100 | Remove Agent tool references. Keep MCP tool instructions (they still apply). | +| `buildTeamCtlOpsInstructions()` | ~100 | Keep — these reference MCP tools which are provider-agnostic. | +| `actionModeInstructions.ts` | 50 | Keep — action modes are prompt-level, not provider-specific. | + +### Tier 4: Tool Approval (Required for Approach A) + +| File/Module | Change Required | +|------------|-----------------| +| Tool approval flow | Mastra has its own `requireApproval: true` on tools + `approveToolCall()`/`declineToolCall()`. Must adapt our UI's approval dialog to use Mastra's API instead of `control_request` stream-json messages. | + +--- + +## Effort Estimate + +### Approach A: Full Mastra Runtime (Replace Claude CLI) + +| Phase | Effort | Risk | +|-------|--------|------| +| Abstract AgentRuntime interface | 2 weeks | Medium — large refactor of 8K line service | +| Implement Mastra runtime adapter | 2 weeks | High — need to reimplement file/terminal/git tools | +| Adapt message parsing pipeline | 1 week | Medium — new adapter for Mastra events | +| Adapt prompt system | 1 week | Low — mostly string template changes | +| Tool approval integration | 1 week | Medium — different approval API | +| Testing + stabilization | 2 weeks | High — regression risk | +| **Total** | **9-10 weeks** | **High** | + +### Approach B: Mastra Middleware (Keep Claude CLI) + +| Phase | Effort | Risk | +|-------|--------|------| +| Mastra orchestrator service | 1 week | Medium | +| Claude CLI adapter tool | 1 week | Low | +| Dual runtime state management | 2 weeks | High — complexity | +| Message flow unification | 1 week | Medium | +| Testing | 1 week | Medium | +| **Total** | **6-7 weeks** | **Medium-High** | + +### Approach C: MCP Bridge (Minimal) + +| Phase | Effort | Risk | +|-------|--------|------| +| @mastra/mcp MCPServer wrapper | 3 days | Low | +| Example Mastra agent consuming our tools | 2 days | Low | +| Documentation + examples | 2 days | Low | +| **Total** | **1-2 weeks** | **Low** | + +--- + +## Risks and Blockers + +### Critical Blockers + +1. **Claude Code's built-in tools are not replicable via Mastra.** + Claude Code has deep integration with the filesystem, terminal, git, and its own Agent tool for spawning teammates. Mastra provides no equivalent — you would need to build `editFile`, `bash`, `readFile`, `glob`, `grep`, `git` tools from scratch. These tools must handle permissions, sandboxing, diff generation, and conflict resolution. This is not just wrapping `fs.writeFile()` — it's thousands of lines of battle-tested code. + +2. **stream-json protocol is Claude Code proprietary.** + Our entire real-time UI (live typing, tool progress, subagent tracking) depends on the stream-json wire format. Mastra's streaming format is different (AI SDK compatible). The translation layer is non-trivial. + +3. **Team/teammate lifecycle is Claude Code's native feature.** + `TeamCreate`, `Agent` with `team_name`, `SendMessage` — these are built into Claude Code CLI. Mastra's supervisor pattern is conceptually similar but mechanically different (in-process sub-agents vs. separate CLI processes). + +4. **Context window management.** + Claude Code manages its own context window, compaction, and session persistence. Mastra delegates this to the model provider's API. Our post-compact reinjection system would need complete redesign. + +### High Risks + +5. **Performance: in-process vs. out-of-process.** + Claude CLI runs as a separate process with its own Node.js runtime. Mastra agents run in-process within Electron's main process. Long-running agent tasks could block the Electron event loop. Would need worker threads or separate Node processes. + +6. **Authentication divergence.** + Claude Code CLI handles its own auth (OAuth, API key). Mastra uses provider API keys directly. Different auth models for different users. + +7. **Losing Claude Code ecosystem.** + Claude Code has CLAUDE.md, settings.json, .mcp.json, hooks, and growing features. Switching to Mastra means losing access to this ecosystem for Claude users. + +### Medium Risks + +8. **Mastra version churn.** + Mastra is pre-1.0 (currently ~1.10.x) and evolving rapidly. The AgentNetwork API was deprecated in favor of supervisor agents in just months. API stability is not guaranteed. + +9. **Dual dependency burden.** + Adding `@mastra/core` (~150KB+ with deps) to an Electron app increases bundle size and potential version conflicts. + +--- + +## Recommendations + +### Short Term (Now): Approach C — MCP Bridge + +**Confidence: 9/10 | Reliability: 8/10** + +- Wrap our MCP server with `@mastra/mcp` MCPServer +- Publish as a standalone MCP endpoint that any Mastra agent can consume +- Zero risk to existing functionality +- Opens the door for external Mastra agents to manage our board +- 1-2 weeks effort + +### Medium Term (Q2-Q3 2026): Abstract AgentRuntime Interface + +**Confidence: 7/10 | Reliability: 6/10** + +- Extract `AgentRuntime` interface from `TeamProvisioningService` +- `ClaudeCliRuntime` implements it (current behavior) +- Prepare the seam for `MastraRuntime` without building it yet +- De-risk the eventual full integration +- 2-3 weeks effort + +### Long Term (Q4 2026+): Approach B — Mastra Middleware + +**Confidence: 6/10 | Reliability: 5/10** + +- Add Mastra as orchestration layer for routing and multi-provider support +- Keep Claude CLI as the "worker" runtime for actual coding +- Use Mastra for decision-making, task routing, and provider switching +- Full multi-model support without losing Claude Code's tooling +- 6-7 weeks effort + +### NOT Recommended: Approach A (Full Replacement) + +**Confidence: 3/10 | Reliability: 2/10** + +Replacing Claude CLI entirely with Mastra-managed agents would lose our core differentiator (deep Claude Code integration: file editing, terminal, git, session persistence, extended thinking, etc.). The effort (~10 weeks) and risk are not justified unless Claude Code CLI is deprecated, which shows no signs of happening. + +--- + +## Sources + +- [Mastra GitHub Repository](https://github.com/mastra-ai/mastra) +- [Mastra Official Documentation](https://mastra.ai/docs) +- [Mastra Agent Overview](https://mastra.ai/docs/agents/overview) +- [Mastra MCP Overview](https://mastra.ai/docs/tools-mcp/mcp-overview) +- [Mastra Agent Network Evolution](https://mastra.ai/blog/agent-network) +- [Mastra vNext Agent Network](https://mastra.ai/blog/vnext-agent-network) +- [Mastra Supervisor Pattern (Feb 2026)](https://mastra.ai/blog/announcing-mastra-improved-agent-orchestration-ai-sdk-v5-support) +- [Mastra Agent Streaming Reference](https://mastra.ai/reference/agents/stream) +- [@mastra/core npm](https://www.npmjs.com/package/@mastra/core) +- [@mastra/mcp npm](https://www.npmjs.com/package/@mastra/mcp) +- [Mastra $13M Seed Round](https://technews180.com/funding-news/mastra-raises-13m-seed-for-typescript-ai-framework/) +- [Mastra on Y Combinator](https://www.ycombinator.com/companies/mastra) diff --git a/docs/research/mastra-vs-direct-mcp.md b/docs/research/mastra-vs-direct-mcp.md new file mode 100644 index 00000000..4a8bc52e --- /dev/null +++ b/docs/research/mastra-vs-direct-mcp.md @@ -0,0 +1,345 @@ +# @mastra/mcp vs Direct MCP: нужна ли нам Mastra как универсальный интеграционный слой? + +**Дата:** 2026-03-24 +**Контекст:** Вопрос пользователя — "Maybe we should use @mastra/mcp since it has many agents built-in?" +**Связанные документы:** +- `docs/research/mastra-integration-analysis.md` — полный технический анализ интеграции Mastra +- `docs/research/best-integration-approach.md` — сравнение всех подходов к мультипровайдерности +- `docs/research/ai-agent-protocols-and-routing.md` — обзор протоколов и фреймворков + +--- + +## Краткий ответ + +**Mastra НЕ даёт нам "many agents built-in" в том смысле, как это звучит.** Mastra — это SDK для создания СВОИХ агентов через API-вызовы к LLM-провайдерам. Она не умеет запускать/управлять CLI-агентами (Claude Code, Codex, Gemini CLI и т.д.) как процессами. Для нашего продукта — Electron-приложения, управляющего CLI-процессами через kanban-доску — прямой MCP остаётся правильным выбором. + +**Итоговая рекомендация: Прямой MCP (Вариант A)** +- Надёжность: 9/10 +- Уверенность: 9/10 + +--- + +## 1. Что такое @mastra/mcp на самом деле + +### Что Mastra НЕ является + +Mastra — это **НЕ** библиотека, которая подключает готовых агентов (Claude Code, Codex, Gemini CLI). Это SDK для создания собственных агентов через API-вызовы. Когда Mastra говорит про "40+ providers" — речь о 40+ LLM-провайдерах (OpenAI, Anthropic, Google и т.д.), к которым можно делать API-запросы, а не о CLI-агентах, которые работают как процессы. + +### Что Mastra ЯВЛЯЕТСЯ + +| Компонент | Описание | +|-----------|----------| +| `@mastra/core` | Agent runtime: создание агентов через `new Agent({model, instructions, tools})` | +| `@mastra/mcp` | MCPClient (подключение к MCP-серверам) + MCPServer (экспорт инструментов) | +| Agent | TS-объект, который вызывает LLM API + tools в цикле | +| Supervisor | Паттерн multi-agent: один агент координирует других | +| Memory | Observational Memory для long-term context | +| Workflows | DAG-based workflow engine | +| ToolSearchProcessor | Динамическая подгрузка инструментов (экономия токенов) | + +### Ключевой момент + +Mastra-агент — это `agent.generate("prompt")` или `agent.stream("prompt")`. Это **HTTP-вызов к LLM API** (OpenAI, Anthropic и т.д.). Это **НЕ** запуск CLI-процесса `claude --input-format stream-json`. + +Наш продукт — менеджер CLI-процессов с kanban-доской. Mastra работает на другом уровне абстракции. + +--- + +## 2. Поддержка MCP у CLI-агентов (март 2026) + +**Ключевой вопрос: если агенты уже поддерживают MCP нативно, зачем нам Mastra как прослойка?** + +| CLI-агент | MCP поддержка | Как настраивается | Источник | +|-----------|---------------|-------------------|----------| +| **Claude Code** | Нативная | `--mcp-config path.json`, `.mcp.json`, `~/.claude.json` | [code.claude.com/docs/en/mcp](https://code.claude.com/docs/en/mcp) | +| **OpenAI Codex** | Нативная | `~/.codex/config.toml`, `codex mcp add` | [developers.openai.com/codex/mcp](https://developers.openai.com/codex/mcp) | +| **Gemini CLI** | Нативная | `~/.gemini/settings.json` | [geminicli.com/docs/tools/mcp-server](https://geminicli.com/docs/tools/mcp-server/) | +| **Goose** | Нативная (MCP — основа расширений) | Built-in, Remote/Stdio/Command | [github.com/block/goose](https://github.com/block/goose) | +| **OpenCode** | Нативная | `opencode.json`, `opencode mcp add` | [opencode.ai/docs/mcp-servers](https://opencode.ai/docs/mcp-servers/) | +| **Kilo Code** | Нативная | `mcp_settings.json`, `.kilocode/mcp.json` | [kilo.ai/docs/automate/mcp/using-in-kilo-code](https://kilo.ai/docs/automate/mcp/using-in-kilo-code) | +| **Aider** | Через адаптеры (mcpm-aider) | MCP-клиент пакеты | [pulsemcp.com/servers/disler-aider](https://www.pulsemcp.com/servers/disler-aider) | + +**Вывод: 6 из 7 основных CLI-агентов уже поддерживают MCP нативно.** Им не нужна Mastra как прослойка — они могут подключиться к нашему MCP-серверу напрямую. + +--- + +## 3. Что Mastra добавляет поверх "сырого" MCP + +### Реальные преимущества Mastra (и почему они нам НЕ нужны) + +| Фича Mastra | Что это | Нужно ли нам? | Почему | +|-------------|---------|---------------|--------| +| **MCPClient** — подключение к нескольким MCP-серверам | Единый клиент для N серверов | Нет | Наш продукт ПРЕДОСТАВЛЯЕТ MCP-сервер, а не потребляет их | +| **MCPServer** — экспорт агентов/инструментов | Expose agents as MCP tools | Нет | У нас уже есть FastMCP сервер с 30+ инструментами | +| **ToolSearchProcessor** — динамический поиск инструментов | Агент ищет нужный инструмент по запросу | Нет | У нас ~30 инструментов, а не сотни. Контекст не проблема | +| **Agent Runtime** — цикл reason-act с memory | Полноценный runtime для API-агентов | Нет | Наши агенты — CLI-процессы (Claude Code, Codex), у них свой runtime | +| **Observability** — MCP_TOOL_CALL spans, Studio UI | Трейсинг MCP-вызовов | Нет | У нас свой UI с timeline, chunks, context tracking | +| **Serverless adapters** — Express/Hono/Koa | Запуск MCP в serverless | Нет | Мы Electron-приложение, не serverless | +| **Multi-registry** — Composio, Smithery | Поиск MCP-серверов в реестрах | Нет | Мы предоставляем один конкретный MCP-сервер | +| **Supervisor pattern** — multi-agent orchestration | Один агент управляет другими | Частично | Но Claude Code Agent Teams УЖЕ делает это нативно через `TeamCreate` + `Agent tool` | +| **600+ моделей** через 40+ провайдеров | Model routing | Нет | CLI-агенты сами решают, какую модель использовать | + +### Что Mastra НЕ может + +| Задача | Может ли Mastra? | Как мы решаем | +|--------|-------------------|---------------| +| Запустить `claude` CLI как процесс | Нет | `spawnCli()` + stream-json | +| Управлять `codex` CLI как subprocess | Нет | Нужен свой ProvisioningService | +| Парсить stream-json stdout | Нет | `handleStreamJsonMessage()` — наш код | +| Использовать Agent Teams built-in tools | Нет | Claude Code нативно | +| Работать с `~/.claude/teams/` файловой системой | Нет | `agent-teams-controller` | +| Показывать kanban-доску | Нет | Наш Electron UI | + +--- + +## 4. Три подхода: сравнение + +### Вариант A: Прямой MCP (наш текущий/рекомендуемый подход) + +**Надёжность: 9/10 | Уверенность: 9/10** + +``` +┌─────────────────────────────┐ +│ Electron App (UI) │ +│ ┌───────┐ ┌──────────┐ │ +│ │Kanban │ │ Timeline │ │ +│ │Board │ │ Messages │ │ +│ └───┬───┘ └────┬─────┘ │ +│ └──────────┘ │ +│ │ IPC │ +├───────────┼─────────────────┤ +│ Main Process │ +│ ┌────────────────────┐ │ +│ │ TeamProvisioning │ │ +│ │ Service │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌─────────┴──────────┐ │ +│ │ MCP Server │ ←── Любой агент подключается сюда +│ │ (agent-teams-mcp) │ через --mcp-config +│ │ 30+ tools │ +│ └─────────────────────┘ │ +└─────────────────────────────┘ + │ │ + ┌────┴────┐ ┌────┴────┐ + │ Claude │ │ Codex / │ + │ Code │ │ Gemini │ + │ CLI │ │ CLI │ + │ (native)│ │ (via │ + │ │ │ MCP) │ + └─────────┘ └─────────┘ +``` + +**Как работает:** +1. Claude Code — нативная интеграция (процесс + stream-json + Agent Teams) +2. Другие агенты (Codex, Gemini, Goose, OpenCode, Kilo) — подключаются к нашему MCP-серверу через свой нативный MCP-клиент +3. Все агенты видят одну kanban-доску, создают задачи, обновляют статусы через MCP tools + +**Трудозатраты:** 0 доп. работы для MCP-части (уже работает). 2-3 недели для новых MCP-инструментов (`team_join`, `task_poll_assigned` и др.) + UI для внешних агентов. + +**Что даёт:** +- Любой MCP-совместимый агент подключается из коробки +- Zero dependency overhead (никаких `@mastra/*` пакетов) +- Наш MCP-сервер — единственная точка интеграции +- Полная совместимость с Claude Code Agent Teams + +**Чего не даёт:** +- Нет встроенного agent-to-agent (A2A) протокола (но он нам и не нужен — у нас inbox-файлы) +- Нет автоматического model routing (но CLI-агенты делают это сами) +- Нет встроенного observability для внешних агентов (но мы видим их действия через MCP-tool calls) + +### Вариант B: @mastra/mcp как обёртка нашего MCP-сервера + +**Надёжность: 5/10 | Уверенность: 4/10** + +``` +┌────────────────────────────────┐ +│ Electron App (UI) │ +├────────────────────────────────┤ +│ Main Process │ +│ ┌────────────────────┐ │ +│ │ TeamProvisioning │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌─────────┴────────────────┐ │ +│ │ @mastra/mcp MCPServer │ │ ← Mastra обёртка +│ │ wraps our FastMCP tools │ │ +│ └─────────┬────────────────┘ │ +│ │ │ +│ ┌─────────┴────────────────┐ │ +│ │ @mastra/mcp MCPClient │ │ ← Mastra клиент +│ │ connects to external │ │ для внешних серверов +│ │ MCP servers │ │ +│ └──────────────────────────┘ │ +└────────────────────────────────┘ +``` + +**Трудозатраты:** 1-2 недели на обёртку + зависимость от `@mastra/core` (~150KB+) + +**Что даёт:** +- Typed MCPClient с auto-detect transport (stdio/HTTP/SSE) +- ToolSearchProcessor для динамического tool loading (если у нас будет 100+ инструментов) +- Tracing integration с Langfuse/LangSmith + +**Чего не даёт:** +- Ничего, что нельзя получить с прямым MCP +- CLI-агенты всё равно подключаются через свой нативный MCP-клиент, а не через Mastra + +**Проблемы:** +- Лишний слой абстракции (FastMCP -> Mastra MCPServer -> MCP protocol -> agent) +- Зависимость от быстро меняющегося фреймворка (Mastra уже менял API agent networks -> supervisors) +- Bundle size increase в Electron (~150KB+ от @mastra/core) +- Нет реальной выгоды: CLI-агенты не используют @mastra/mcp — они используют свои нативные MCP-клиенты + +### Вариант C: Mastra как оркестратор (создаёт/управляет агентами программно) + +**Надёжность: 3/10 | Уверенность: 3/10** + +``` +┌────────────────────────────────┐ +│ Electron App (UI) │ +├────────────────────────────────┤ +│ Main Process │ +│ ┌──────────────────────────┐ │ +│ │ Mastra Supervisor Agent │ │ ← Mastra управляет всем +│ │ model: anthropic/... │ │ +│ │ agents: { worker1, ... }│ │ +│ │ tools: { task_create } │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ┌──────────┴───────────────┐ │ +│ │ Mastra Sub-Agents │ │ ← API-based, не CLI +│ │ openai/gpt-4o │ │ +│ │ anthropic/claude-sonnet │ │ +│ │ google/gemini-2.5-pro │ │ +│ └──────────────────────────┘ │ +└────────────────────────────────┘ +``` + +**Трудозатраты:** 8-12 недель + +**Что даёт:** +- Полная мультимодельность через API (40+ провайдеров) +- Mastra memory, workflows, evals + +**Чего не даёт:** +- Claude Code Agent Teams (нативные инструменты CLI: file editing, terminal, git, session persistence) +- Управление CLI-процессами +- Парсинг JSONL-сессий +- Всё, что делает наш продукт уникальным + +**Проблемы:** +- **Полностью ломает наш продукт.** Мы перестаём быть "Claude Agent Teams UI" и становимся "ещё один Mastra-based agent manager" +- Нужно заново реализовать file editing, bash, git tools (тысячи строк battle-tested кода в Claude Code) +- Теряем CLAUDE.md, hooks, settings.json, extended thinking — весь экосистемный Claude Code +- Mastra-агенты — API-based. Они НЕ запускаются как CLI-процессы с своим terminal и git integration + +--- + +## 5. Что насчёт "Skills" — 40+ AI агентов в Mastra? + +Это отдельная тема, которая может ввести в заблуждение. + +**"Skills" в Mastra — это НЕ готовые агенты.** Это markdown-файлы с инструкциями (CLAUDE.md, AGENTS.md), которые учат внешних AI coding agents (Claude Code, Cursor, Windsurf, Copilot и т.д.) использовать Mastra API. То есть Mastra генерирует `.cursor/rules` или `CLAUDE.md` с документацией по своему SDK. + +Список из 40+ "агентов" (AdaL, Amp, Antigravity, Augment, CodeBuddy, Crush, Droid, Goose, Kilo, Kimi CLI, Kiro CLI, Kode и т.д.) — это список IDE/CLI tools, для которых Mastra может сгенерировать instruction files. Это **НЕ** то, что Mastra может программно запускать или управлять. + +--- + +## 6. Экосистема: инструменты для оркестрации CLI-агентов + +Для полноты картины — вот что существует в марте 2026 для управления CLI-агентами как процессами (наша задача): + +| Инструмент | Что делает | GitHub | Подходит нам? | +|------------|-----------|--------|---------------| +| **CCManager** | Session manager для Claude/Codex/Gemini/OpenCode/Kilo CLI | [kbwo/ccmanager](https://github.com/kbwo/ccmanager) | Нет — TUI, не Electron; нет kanban | +| **MCO** | Neutral orchestration layer для CLI-агентов | [mco-org/mco](https://github.com/mco-org/mco) | Частично — dispatch layer, но без UI | +| **Mozzie** | Desktop tool для параллельной оркестрации | [ProductHunt](https://www.producthunt.com/products/mozzie) | Конкурент | +| **Nexus MCP** | MCP-сервер для вызова CLI-агентов как tools | [glama.ai](https://glama.ai/mcp/servers/j7an/nexus-mcp) | Интересно — позволяет одному агенту вызывать другой через MCP | +| **claude-code-teams-mcp** | Reimplementation Agent Teams как standalone MCP server | [cs50victor/claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | Валидирует наш подход — MCP как universal integration layer | + +**Вывод:** Индустрия движется к MCP как универсальному протоколу, а не к Mastra как универсальному фреймворку. Mastra — для создания API-based агентов. MCP — для интеграции любых агентов с инструментами. + +--- + +## 7. Сводная таблица + +| Критерий | Вариант A: Прямой MCP | Вариант B: @mastra/mcp обёртка | Вариант C: Mastra оркестратор | +|----------|----------------------|-------------------------------|------------------------------| +| **Трудозатраты** | 2-3 недели (новые MCP tools) | 3-4 недели | 8-12 недель | +| **Что ломается** | Ничего | Ничего (additive) | Всё | +| **Code reuse** | 100% | 100% | ~20% | +| **Мультипровайдерность** | Любой MCP-совместимый агент | Любой MCP-совместимый агент | 40+ API провайдеров | +| **CLI-агенты** | Нативная поддержка | Нативная поддержка | Не поддерживаются | +| **Bundle size** | +0 KB | +150 KB+ (@mastra/core) | +150 KB+ | +| **Зависимость от Mastra** | Нет | Слабая | Полная | +| **Риск** | Очень низкий | Низкий | Очень высокий | +| **Наш продукт остаётся?** | Да | Да | Нет — становится другим продуктом | +| **Уникальность** | Kanban + Claude Code Teams + MCP | Kanban + Claude Code Teams + MCP | Ещё один Mastra-based agent manager | +| **Надёжность** | 9/10 | 5/10 | 3/10 | +| **Уверенность** | 9/10 | 4/10 | 3/10 | + +--- + +## 8. Финальная рекомендация + +### Не используем @mastra/mcp. Используем прямой MCP. + +**Причины:** + +1. **Mastra решает не нашу проблему.** Mastra — SDK для создания API-based агентов. Наш продукт — менеджер CLI-процессов. Разные домены. + +2. **CLI-агенты уже поддерживают MCP нативно.** Claude Code, Codex, Gemini CLI, Goose, OpenCode, Kilo — все могут подключиться к нашему MCP-серверу без Mastra. + +3. **@mastra/mcp — лишний слой.** CLI-агенты не используют Mastra MCPClient. Они используют свои нативные MCP-клиенты. Mastra MCPServer просто обернёт наш FastMCP-сервер без добавления ценности. + +4. **Наш MCP-сервер уже работает.** 30+ инструментов, battle-tested с Claude Code Agent Teams. Нужно добавить 5-8 новых инструментов для external agents — и готово. + +5. **Zero dependency = zero risk.** Mastra меняет API быстро (agent networks -> supervisors за месяцы). Прямой MCP — стабильный стандарт (v1.0+, AAIF/Linux Foundation). + +6. **Наше конкурентное преимущество — kanban + Claude Code Agent Teams.** Mastra не усиливает это. Mastra превращает нас в generic agent manager, которых уже десятки. + +### Что делать вместо Mastra + +Следовать плану из `docs/research/best-integration-approach.md` — **Option 7: Hybrid**: + +1. **Phase 1 (неделя 1-2):** Добавить MCP-инструменты для external agents: `team_join`, `team_leave`, `task_poll_assigned`, `task_claim`, `member_register`, `member_heartbeat` +2. **Phase 2 (неделя 2-3):** UI-поддержка внешних агентов: provider badge, external member type +3. **Phase 3 (неделя 3-4):** Notification mechanism (polling, SSE) +4. **Phase 4 (по запросу):** Нативная поддержка второго CLI-агента (Codex) через `AgentRuntime` abstraction + +### Когда Mastra МОЖЕТ понадобиться + +- Если мы решим создавать **API-based агентов** для задач, не требующих CLI (code review, planning, triage) — Mastra Agent + наш MCP server +- Если мы решим добавить **ToolSearchProcessor** для discovery среди сотен инструментов (сейчас у нас 30+, не актуально) +- Если мы решим экспортировать наши агенты/workflow как **standalone MCP server** для внешних систем (Mastra MCPServer может быть удобнее FastMCP) +- Если Claude Code CLI будет **deprecated** (никаких признаков этого) + +Но это всё сценарии "если" на далёкое будущее. Сейчас прямой MCP — правильный и достаточный выбор. + +--- + +## Источники + +- [Mastra GitHub Repository (22K+ stars)](https://github.com/mastra-ai/mastra) +- [Mastra MCP Overview](https://mastra.ai/docs/mcp/overview) +- [Mastra Agents Overview](https://mastra.ai/docs/agents/overview) +- [Mastra Agent Networks](https://mastra.ai/docs/agents/networks) +- [@mastra/mcp npm](https://www.npmjs.com/package/@mastra/mcp) +- [Why We're All-In on MCP (Mastra Blog)](https://mastra.ai/blog/mastra-mcp) +- [Mastra 1.0 Announcement (300K+ weekly downloads, 19.4K stars)](https://mastra.ai/blog/announcing-mastra-1) +- [Mastra Changelog 2026-03-12](https://mastra.ai/blog/changelog-2026-03-12) +- [Claude Code MCP Docs](https://code.claude.com/docs/en/mcp) +- [OpenAI Codex MCP](https://developers.openai.com/codex/mcp) +- [Gemini CLI MCP](https://geminicli.com/docs/tools/mcp-server/) +- [Goose — open source AI agent by Block](https://github.com/block/goose) +- [OpenCode MCP](https://opencode.ai/docs/mcp-servers/) +- [Kilo Code MCP](https://kilo.ai/docs/automate/mcp/using-in-kilo-code) +- [Aider MCP Server](https://www.pulsemcp.com/servers/disler-aider) +- [claude-code-teams-mcp (standalone reimplementation)](https://github.com/cs50victor/claude-code-teams-mcp) +- [CCManager (session manager)](https://github.com/kbwo/ccmanager) +- [MCO (multi-agent orchestrator)](https://github.com/mco-org/mco) +- [Nexus MCP (CLI agents as MCP tools)](https://glama.ai/mcp/servers/j7an/nexus-mcp) +- [Mastra ToolSearchProcessor (Feb 2026)](https://mastra.ai/blog/changelog-2026-02-04) +- [Google Official MCP Support Announcement](https://cloud.google.com/blog/products/ai-machine-learning/announcing-official-mcp-support-for-google-services) +- [Agentic AI Foundation (AAIF) — Linux Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) diff --git a/docs/research/minimal-adapter-design.md b/docs/research/minimal-adapter-design.md new file mode 100644 index 00000000..774f3736 --- /dev/null +++ b/docs/research/minimal-adapter-design.md @@ -0,0 +1,689 @@ +# Minimal CLI Agent Adapter Design + +**Дата**: 2026-03-25 +**Статус**: Research / Design proposal + +## Цель + +Определить МИНИМАЛЬНО достаточный адаптер для запуска нескольких CLI-агентов (Claude, Codex, Gemini, Goose, OpenCode) из нашего Electron-приложения. Без over-engineering, без "велосипедов". + +--- + +## 1. Что мы уже имеем + +### childProcess.ts (221 LOC) +Уже содержит два ключевых примитива: +- **`spawnCli(binaryPath, args, options)`** — spawn с Windows EINVAL fallback +- **`execCli(binaryPath, args, options)`** — exec для одноразовых команд +- **`killProcessTree(child, signal)`** — kill с Windows taskkill fallback +- **`CLI_ENV_DEFAULTS`** — env-переменные для Claude (CLAUDE_HOOK_JUDGE_MODE) + +### TeamProvisioningService.ts (~8000+ LOC) +Монстр, который делает ВСЁ: +- Spawn через `spawnCli()` +- Конструирование args (`--input-format stream-json`, `--output-format stream-json`, `--mcp-config`, `--verbose`, etc.) +- Парсинг stream-json stdout (newline-delimited JSON) +- Stdin messaging (SDKUserMessage format) +- MCP config merge (через TeamMcpConfigBuilder) +- Filesystem monitoring, stall detection, auth retry, etc. + +### ScheduledTaskExecutor.ts (~200 LOC) +Отдельный, более чистый spawn-path для scheduled tasks: +- Тоже `spawnCli()` + `--output-format stream-json` +- Парсинг stdout для summary extraction +- Простой lifecycle: spawn -> wait -> collect result + +### TeamMcpConfigBuilder.ts (229 LOC) +Генерирует MCP config JSON-файл, мержит с user-серверами из `~/.claude.json`. + +### Общий паттерн spawn (из TeamProvisioningService): +```typescript +const spawnArgs = [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user,project,local', + '--mcp-config', mcpConfigPath, + '--disallowedTools', 'TeamDelete,TodoWrite', + ...(skipPermissions ? ['--dangerously-skip-permissions'] : []), + ...(model ? ['--model', model] : []), +]; +child = spawnCli(claudePath, spawnArgs, { + cwd, env, stdio: ['pipe', 'pipe', 'pipe'], +}); +// stdin: send JSON messages +child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: prompt }] } +}) + '\n'); +``` + +--- + +## 2. Что РЕАЛЬНО отличается между CLI-агентами + +### Сводная таблица (исследование март 2026) + +| Аспект | Claude Code | Codex (OpenAI) | Gemini CLI | Goose (Block) | OpenCode | +|--------|-------------|-----------------|------------|---------------|----------| +| **Binary** | `claude` | `codex` | `gemini` | `goose` | `opencode` | +| **Programmatic mode** | `--input-format stream-json --output-format stream-json` | `codex exec --json` (NDJSON events) | `--output-format json` (headless) | `goose run --output-format stream-json` | `opencode run --format json` | +| **Stdin messaging** | stream-json protocol (SDKUserMessage) | Нет stdin — одноразовый exec | Нет stdin — одноразовый | Нет stdin — одноразовый `run` | Нет stdin — pipe prompt или `--attach` | +| **Output protocol** | NDJSON (type: user/assistant/result/control_request/system) | NDJSON events | JSON (структура неизвестна) | NDJSON (text/json/stream-json) | JSON events | +| **MCP config** | `--mcp-config /path/to/file.json` | `config.toml` (`codex mcp add`) | `settings.json` (`gemini mcp add`) | `--with-extension "cmd"` (runtime) | Config file (opencode.json) | +| **MCP config format** | `{ mcpServers: { name: { command, args } } }` | TOML (встроенная команда `codex mcp`) | JSON settings.json `{ mcpServers: {...} }` | CLI flags per extension | JSON config | +| **Kill semantics** | SIGKILL (team) / SIGTERM (scheduled) | SIGTERM | SIGTERM | SIGTERM | SIGTERM | +| **Keep-alive** | Да (stream-json stdin/stdout loop) | Нет (exec = one-shot) | Нет (headless = one-shot) | Нет (run = one-shot) | Возможно (`--attach` к serve) | +| **Team/multi-agent** | Нативные Agent Teams (TeamCreate, SendMessage) | Нет встроенного | Нет встроенного | Нет встроенного | Subagents через Task tool | +| **Prompt flag** | Stdin (stream-json) или `-p` (one-shot) | `codex exec "prompt"` (positional) | `-p "prompt"` или pipe | `goose run -t "prompt"` или `-i file` | `opencode run "prompt"` (positional) | + +### Источники +- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) — `codex exec --json`, NDJSON events +- [Codex MCP Docs](https://developers.openai.com/codex/mcp) — config.toml based MCP +- [Gemini CLI MCP Docs](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) — settings.json, `gemini mcp add` +- [Goose CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) — `--output-format stream-json`, `--with-extension` +- [Goose --output-format issue #4419](https://github.com/block/goose/issues/4419) — json/stream-json Done +- [OpenCode CLI Docs](https://opencode.ai/docs/cli/) — `run --format json` +- [OpenCode Agents Docs](https://opencode.ai/docs/agents/) — subagents, Task tool + +--- + +## 3. Ключевой вывод: ГДЕ реальная сложность + +### Что тривиально (просто конфиг): +- **Binary name** — строка +- **Prompt flag** — `-p`, `-t`, позиционный arg, или stdin +- **Output format flag** — `--output-format stream-json`, `--json`, `--format json` +- **Model flag** — `--model`, `-m`, `--provider/--model` +- **Permission flags** — `--dangerously-skip-permissions`, `--full-auto`, `--yolo` +- **Kill signal** — SIGKILL vs SIGTERM + +### Что НЕ тривиально (требует адаптера): +1. **Stdin protocol** — ТОЛЬКО Claude имеет persistent stdin loop (stream-json). Все остальные — one-shot (запустил, получил результат, процесс завершился). Это ФУНДАМЕНТАЛЬНОЕ отличие. +2. **Output parsing** — NDJSON формат похож, но структура объектов разная. Claude: `{type: "assistant", message: {...}}`. Codex: свой формат events. Goose: свой. Gemini: свой. +3. **MCP config injection** — Claude: `--mcp-config file.json`. Codex: нужно `codex mcp add` заранее или config.toml. Gemini: нужно `gemini mcp add` или settings.json. Goose: `--with-extension` per runtime. + +### Честная оценка: что из 8000 LOC TeamProvisioningService нужно для других CLI? + +**НЕ нужно** (Claude-specific, 80% кода): +- stream-json stdin messaging loop +- `control_request` protocol (tool approval) +- Teammate spawn tracking (`memberSpawnStatuses`) +- Agent Teams protocol (TeamCreate, SendMessage, TaskCreate) +- Post-compact context recovery +- Cross-team messaging relay +- Lead activity state machine +- Filesystem monitoring для team files (config.json, inboxes/, tasks/) +- Auth retry через respawn + +**Нужно** (общий ~20% skeleton): +- Binary resolution (`ClaudeBinaryResolver` -> обобщённый) +- Shell env resolution (`resolveInteractiveShellEnv`) +- MCP config generation и injection +- Process spawn + stdio pipes +- stdout/stderr collection +- Kill + cleanup +- Timeout/stall detection +- Progress reporting + +--- + +## 4. Три варианта дизайна + +### Option A: Config-driven (одна функция + конфиг) + +**~120 LOC total** (config object + spawnAgent function + output normalizer) + +```typescript +// src/main/utils/agentConfig.ts (~60 LOC) + +export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode'; + +export type OutputProtocol = 'stream-json' | 'ndjson-events' | 'json-batch'; + +/** How to inject the user prompt into the CLI */ +export type PromptMode = + | { type: 'stdin-stream-json' } // Claude: persistent stdin loop + | { type: 'flag'; flag: string } // -p "prompt", -t "prompt" + | { type: 'positional' } // codex exec "prompt" + | { type: 'stdin-pipe' }; // echo "prompt" | opencode run + +export interface AgentConfig { + /** Binary name (resolved via PATH or explicit path) */ + bin: string; + /** How to pass the prompt */ + promptMode: PromptMode; + /** CLI flags for programmatic output */ + outputArgs: string[]; + /** How stdout should be parsed */ + outputProtocol: OutputProtocol; + /** How to inject MCP servers */ + mcpInjection: + | { type: 'flag'; flag: string; format: 'claude-json' } // --mcp-config file.json + | { type: 'runtime-flag'; flag: string } // --with-extension "cmd" + | { type: 'config-file'; path: string; format: 'toml' | 'json' } // write to config + | { type: 'cli-command'; command: string[] }; // codex mcp add ... + /** Signal to use for killing */ + killSignal: NodeJS.Signals; + /** Extra env vars */ + env?: Record; + /** Whether the process stays alive for multi-turn (only Claude) */ + persistent: boolean; +} + +export const AGENT_CONFIGS: Record = { + claude: { + bin: 'claude', + promptMode: { type: 'stdin-stream-json' }, + outputArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], + outputProtocol: 'stream-json', + mcpInjection: { type: 'flag', flag: '--mcp-config', format: 'claude-json' }, + killSignal: 'SIGKILL', + env: { CLAUDE_HOOK_JUDGE_MODE: 'true' }, + persistent: true, + }, + codex: { + bin: 'codex', + promptMode: { type: 'positional' }, + outputArgs: ['exec', '--json'], + outputProtocol: 'ndjson-events', + mcpInjection: { type: 'config-file', path: '~/.codex/config.toml', format: 'toml' }, + killSignal: 'SIGTERM', + persistent: false, + }, + gemini: { + bin: 'gemini', + promptMode: { type: 'flag', flag: '-p' }, + outputArgs: ['--output-format', 'json'], + outputProtocol: 'json-batch', + mcpInjection: { type: 'cli-command', command: ['gemini', 'mcp', 'add'] }, + killSignal: 'SIGTERM', + persistent: false, + }, + goose: { + bin: 'goose', + promptMode: { type: 'flag', flag: '-t' }, + outputArgs: ['run', '--output-format', 'stream-json'], + outputProtocol: 'stream-json', + mcpInjection: { type: 'runtime-flag', flag: '--with-extension' }, + killSignal: 'SIGTERM', + persistent: false, + }, + opencode: { + bin: 'opencode', + promptMode: { type: 'positional' }, + outputArgs: ['run', '--format', 'json'], + outputProtocol: 'json-batch', + mcpInjection: { type: 'config-file', path: '.opencode.json', format: 'json' }, + killSignal: 'SIGTERM', + persistent: false, + }, +}; +``` + +```typescript +// src/main/utils/agentSpawn.ts (~60 LOC) + +import { spawnCli, killProcessTree } from './childProcess'; +import { AGENT_CONFIGS, type AgentType, type AgentConfig } from './agentConfig'; + +export interface AgentSpawnOptions { + type: AgentType; + prompt: string; + cwd: string; + env?: NodeJS.ProcessEnv; + model?: string; + mcpConfigPath?: string; // pre-built MCP config file (for Claude-style --mcp-config) + extraArgs?: string[]; +} + +export interface SpawnedAgent { + child: import('child_process').ChildProcess; + config: AgentConfig; + kill: () => void; + /** Send message (only works for persistent agents like Claude) */ + send?: (text: string) => void; +} + +export function spawnAgent(options: AgentSpawnOptions): SpawnedAgent { + const config = AGENT_CONFIGS[options.type]; + const args: string[] = [...config.outputArgs]; + + // Inject MCP config + if (options.mcpConfigPath && config.mcpInjection.type === 'flag') { + args.push(config.mcpInjection.flag, options.mcpConfigPath); + } + + // Extra args + if (options.extraArgs) { + args.push(...options.extraArgs); + } + + // Inject prompt based on mode + switch (config.promptMode.type) { + case 'flag': + args.push(config.promptMode.flag, options.prompt); + break; + case 'positional': + args.push(options.prompt); + break; + case 'stdin-stream-json': + case 'stdin-pipe': + // Handled after spawn + break; + } + + const child = spawnCli(config.bin, args, { + cwd: options.cwd, + env: { ...(options.env ?? process.env), ...(config.env ?? {}) }, + stdio: config.persistent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], + }); + + // Send prompt via stdin if needed + if (config.promptMode.type === 'stdin-stream-json' && child.stdin?.writable) { + const msg = JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: options.prompt }] }, + }); + child.stdin.write(msg + '\n'); + } else if (config.promptMode.type === 'stdin-pipe' && child.stdin) { + child.stdin.write(options.prompt); + child.stdin.end(); + } + + return { + child, + config, + kill: () => killProcessTree(child, config.killSignal), + send: config.persistent + ? (text: string) => { + if (!child.stdin?.writable) return; + const msg = JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }); + child.stdin.write(msg + '\n'); + } + : undefined, + }; +} +``` + +**Плюсы:** +- Минимум кода (~120 LOC в двух файлах) +- Нет классов, нет наследования, нет интерфейсов +- Новый CLI = добавить запись в AGENT_CONFIGS +- Легко тестировать (pure config + one function) +- Не ломает существующий код — TeamProvisioningService может использовать или не использовать + +**Минусы:** +- Output parsing НЕ покрыт (каждый CLI имеет свою структуру NDJSON) +- MCP config injection для Codex/Gemini требует отдельной логики (write to config.toml, run `gemini mcp add`) +- `persistent: true` (Claude) vs one-shot (все остальные) — фундаментально разный lifecycle + +**Надёжность: 7/10** — Покрывает spawn, но не parsing. +**Уверенность: 8/10** — Config-based подход проверен в ScheduledTaskExecutor. + +--- + +### Option B: Thin interface + implementations + +**~200 LOC total** (interface + claude adapter + generic one-shot adapter) + +```typescript +// src/main/adapters/AgentAdapter.ts (~30 LOC) + +import type { ChildProcess } from 'child_process'; + +export interface AgentOutput { + type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'result' | 'error' | 'raw'; + content: string; + raw?: unknown; +} + +export interface AgentAdapter { + readonly agentType: string; + readonly persistent: boolean; + + /** Build CLI args for spawning */ + buildArgs(prompt: string, options: { model?: string; mcpConfigPath?: string; extraArgs?: string[] }): string[]; + + /** Parse a single line/chunk of stdout into normalized output */ + parseOutput(line: string): AgentOutput | null; + + /** Send a follow-up message (only for persistent agents) */ + sendMessage?(child: ChildProcess, text: string): void; + + /** Which signal to use for kill */ + killSignal: NodeJS.Signals; +} +``` + +```typescript +// src/main/adapters/ClaudeAdapter.ts (~60 LOC) +export class ClaudeAdapter implements AgentAdapter { + readonly agentType = 'claude'; + readonly persistent = true; + readonly killSignal = 'SIGKILL' as const; + + buildArgs(prompt: string, options) { + const args = [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + ]; + if (options.mcpConfigPath) args.push('--mcp-config', options.mcpConfigPath); + if (options.model) args.push('--model', options.model); + args.push(...(options.extraArgs ?? [])); + return args; + // prompt sent via sendMessage(), not in args + } + + parseOutput(line: string): AgentOutput | null { + try { + const obj = JSON.parse(line); + if (obj.type === 'assistant') return { type: 'text', content: /* extract */, raw: obj }; + if (obj.type === 'result') return { type: 'result', content: obj.result?.text ?? '', raw: obj }; + return { type: 'raw', content: line, raw: obj }; + } catch { return null; } + } + + sendMessage(child: ChildProcess, text: string) { + if (!child.stdin?.writable) return; + child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'); + } +} +``` + +```typescript +// src/main/adapters/OneShotAdapter.ts (~80 LOC) +// Generic one-shot adapter configurable for Codex, Goose, Gemini, OpenCode + +export interface OneShotConfig { + agentType: string; + subcommand?: string; // 'exec', 'run', etc. + outputFlag: string[]; // ['--json'], ['--output-format', 'stream-json'], etc. + promptFlag?: string; // '-p', '-t', or undefined for positional + mcpFlag?: string; // '--with-extension' for goose + killSignal?: NodeJS.Signals; +} + +export class OneShotAdapter implements AgentAdapter { + readonly persistent = false; + readonly agentType: string; + readonly killSignal: NodeJS.Signals; + private config: OneShotConfig; + + constructor(config: OneShotConfig) { + this.config = config; + this.agentType = config.agentType; + this.killSignal = config.killSignal ?? 'SIGTERM'; + } + + buildArgs(prompt: string, options) { + const args: string[] = []; + if (this.config.subcommand) args.push(this.config.subcommand); + args.push(...this.config.outputFlag); + if (options.mcpConfigPath && this.config.mcpFlag) { + args.push(this.config.mcpFlag, options.mcpConfigPath); + } + args.push(...(options.extraArgs ?? [])); + if (this.config.promptFlag) { + args.push(this.config.promptFlag, prompt); + } else { + args.push(prompt); // positional + } + return args; + } + + parseOutput(line: string): AgentOutput | null { + try { + const obj = JSON.parse(line); + return { type: 'raw', content: line, raw: obj }; + } catch { return null; } + } +} + +// Pre-built instances: +export const codexAdapter = new OneShotAdapter({ + agentType: 'codex', subcommand: 'exec', outputFlag: ['--json'], killSignal: 'SIGTERM', +}); +export const gooseAdapter = new OneShotAdapter({ + agentType: 'goose', subcommand: 'run', outputFlag: ['--output-format', 'stream-json'], + promptFlag: '-t', mcpFlag: '--with-extension', +}); +export const geminiAdapter = new OneShotAdapter({ + agentType: 'gemini', outputFlag: ['--output-format', 'json'], promptFlag: '-p', +}); +export const opencodeAdapter = new OneShotAdapter({ + agentType: 'opencode', subcommand: 'run', outputFlag: ['--format', 'json'], +}); +``` + +**Плюсы:** +- `parseOutput()` даёт место для нормализации вывода каждого CLI +- Чёткое разделение: Claude (persistent) vs all others (one-shot) +- `OneShotAdapter` — generic, покрывает 4 из 5 CLI одним классом +- Новый CLI = `new OneShotAdapter({ ... })` (одна строка) + +**Минусы:** +- Интерфейс + 2 класса — чуть больше "архитектуры" чем нужно прямо сейчас +- `parseOutput()` для не-Claude CLI будет пустышкой (return raw) пока не изучим их NDJSON формат +- Всё ещё не решает MCP injection для Codex (config.toml) и Gemini (settings.json) + +**Надёжность: 8/10** — Хороший баланс между простотой и расширяемостью. +**Уверенность: 7/10** — Interface-based подход стандартен, но `parseOutput` рискует стать "мёртвым кодом" на начальном этапе. + +--- + +### Option C: Расширить childProcess.ts (минимальные изменения) **(Recommended)** + +**~50 LOC additions** к существующему файлу + **~30 LOC** отдельный config + +```typescript +// Добавить в src/main/utils/childProcess.ts (~25 LOC) + +export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode'; + +export interface AgentSpawnResult { + child: ChildProcess; + send?: (text: string) => void; + kill: () => void; +} + +/** + * Spawn any supported CLI agent. Thin wrapper over spawnCli that + * handles binary name, output-format flags, and prompt injection. + */ +export function spawnAgent( + type: AgentType, + binaryPath: string, + prompt: string, + options: SpawnOptions & { mcpConfigPath?: string; extraArgs?: string[] } = {} +): AgentSpawnResult { + const cfg = AGENT_SPAWN_CONFIGS[type]; + const args = [...cfg.baseArgs]; + if (options.mcpConfigPath && cfg.mcpFlag) { + args.push(cfg.mcpFlag, options.mcpConfigPath); + } + if (options.extraArgs) args.push(...options.extraArgs); + if (cfg.promptFlag) args.push(cfg.promptFlag, prompt); + else if (!cfg.stdinPrompt) args.push(prompt); + + const child = spawnCli(binaryPath, args, { + ...options, + env: { ...(options.env ?? process.env), ...(cfg.env ?? {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Inject prompt via stdin if needed + if (cfg.stdinPrompt && child.stdin?.writable) { + const msg = cfg.stdinPrompt === 'stream-json' + ? JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } }) + '\n' + : prompt; + child.stdin.write(msg); + if (cfg.stdinPrompt === 'pipe') child.stdin.end(); + } + + return { + child, + send: cfg.stdinPrompt === 'stream-json' + ? (text: string) => { + if (!child.stdin?.writable) return; + child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'); + } + : undefined, + kill: () => killProcessTree(child, cfg.killSignal), + }; +} +``` + +```typescript +// src/main/utils/agentConfigs.ts (~30 LOC) + +interface AgentSpawnConfig { + baseArgs: string[]; + promptFlag?: string; // undefined = positional arg + stdinPrompt?: 'stream-json' | 'pipe'; + mcpFlag?: string; + killSignal: NodeJS.Signals; + env?: Record; +} + +export const AGENT_SPAWN_CONFIGS: Record = { + claude: { + baseArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], + stdinPrompt: 'stream-json', + mcpFlag: '--mcp-config', + killSignal: 'SIGKILL', + env: { CLAUDE_HOOK_JUDGE_MODE: 'true' }, + }, + codex: { + baseArgs: ['exec', '--json'], + killSignal: 'SIGTERM', + }, + gemini: { + baseArgs: ['--output-format', 'json'], + promptFlag: '-p', + killSignal: 'SIGTERM', + }, + goose: { + baseArgs: ['run', '--output-format', 'stream-json'], + promptFlag: '-t', + mcpFlag: '--with-extension', + killSignal: 'SIGTERM', + }, + opencode: { + baseArgs: ['run', '--format', 'json'], + killSignal: 'SIGTERM', + }, +}; +``` + +**Плюсы:** +- Абсолютный минимум нового кода (~55 LOC) +- Не создаёт новую абстракцию — расширяет существующую +- TeamProvisioningService может постепенно мигрировать (или нет) +- Новый CLI = 5 строк в конфиге +- Binary resolution остаётся на вызывающей стороне (как сейчас с ClaudeBinaryResolver) +- Output parsing — ответственность вызывающего кода (не навязываем) + +**Минусы:** +- Не покрывает output parsing (сознательно) +- Не покрывает MCP config injection для Codex/Gemini +- childProcess.ts станет чуть толще (~275 LOC вместо 221) +- Нет типизации вывода (каждый consumer парсит сам) + +**Надёжность: 7/10** — Минимально, но достаточно для spawn. +**Уверенность: 9/10** — Расширение существующего утилитного файла — самый безопасный путь. + +--- + +## 5. Сравнительная таблица + +| Критерий | Option A (config+fn) | Option B (interface) | Option C (extend existing) | +|----------|---------------------|---------------------|---------------------------| +| **LOC** | ~120 | ~200 | ~55 | +| **Новых файлов** | 2 | 3 | 1 | +| **Output parsing** | Нет | Да (заглушка) | Нет | +| **MCP injection** | Описано, не реализовано | Описано, не реализовано | Описано, не реализовано | +| **Расширяемость** | Хорошая (конфиг) | Отличная (интерфейс) | Хорошая (конфиг) | +| **Breaks existing?** | Нет | Нет | Нет | +| **Time to implement** | 1 час | 2 часа | 30 мин | +| **"Велосипед"?** | Нет, это конфиг | Нет, но чуть преждевременно | Нет, это 55 строк клея | + +--- + +## 6. Рекомендация + +### Начать с Option C (extend childProcess.ts), при необходимости вырастить в Option A + +**Почему:** + +1. **55 LOC — это не велосипед.** Это минимальный config-driven dispatcher. Любой проект, интегрирующий несколько CLI, пишет ровно это. Нет смысла тянуть зависимость ради 55 строк. + +2. **Output parsing — отдельная задача.** Парсинг NDJSON от Codex/Gemini/Goose — это ~50-100 LOC на каждый CLI, и его не нужно решать сейчас. Когда понадобится — это будет Option B (interface с `parseOutput()`), но не раньше. + +3. **MCP injection — тоже отдельная задача.** Для Claude у нас уже есть TeamMcpConfigBuilder. Для Goose — это просто `--with-extension`. Для Codex/Gemini — нужно писать в их config files. Это 3 отдельных утилиты, не общий адаптер. + +4. **Persistent vs one-shot — фундаментально разный lifecycle.** Claude (stream-json loop) живёт долго и получает новые сообщения. Все остальные — fire-and-forget. Эту разницу нельзя "спрятать" за единым интерфейсом без того чтобы интерфейс не стал дырявой абстракцией. + +### Эволюционный путь: + +``` +Этап 1 (сейчас): Option C — spawnAgent() в childProcess.ts + agentConfigs.ts + 55 LOC, покрывает spawn для всех 5 CLI + +Этап 2 (когда добавим 2-й CLI): Вынести в отдельный файл если childProcess.ts станет перегруженным + Может стать Option A (~120 LOC) + +Этап 3 (когда нужен output parsing): Добавить parseOutput() per agent + Может стать Option B (~200 LOC) +``` + +--- + +## 7. Честный ответ: "велосипед" или нет? + +**Нет, это НЕ велосипед.** Вот почему: + +1. **Нет готовой библиотеки.** Не существует npm-пакета "universal-cli-agent-spawner". Каждый из этих CLI — молодой продукт (2025-2026), с собственным протоколом. Никто ещё не написал унификатор. + +2. **55-200 LOC клея — это норма.** Для сравнения: + - Docker SDK для Node.js: ~300 LOC для spawn docker CLI + - Terraform CDK: ~200 LOC для spawn terraform binary + - VS Code extensions: ~150 LOC для spawn language server + +3. **Наш существующий spawnCli() — уже 65 LOC** клея для одного Claude CLI. Расширить его до 5 CLI за +55 LOC — это линейное масштабирование, не экспоненциальное. + +4. **Реальный "велосипед" начался бы** если бы мы писали: + - Свой MCP client (~500+ LOC) + - Свой NDJSON parser с backpressure (~200 LOC) + - Свой process supervisor с restart policies (~400 LOC) + - Свой auth token manager per CLI (~300 LOC) + + Мы этого НЕ делаем. Мы пишем config map + одну функцию. + +5. **Большую часть сложности (8000 LOC TeamProvisioningService) мы уже написали** для Claude — и она Claude-specific. Адаптер для других CLI будет использовать ~5% от этого кода. + +--- + +## 8. Что НЕ включать в адаптер + +Явно НЕ входит в scope минимального адаптера: +- Output parsing/normalization (отдельный слой) +- Team protocol (Agent Teams — Claude-only) +- MCP config generation (отдельный builder per CLI) +- Binary auto-discovery/installation (отдельный resolver per CLI) +- Auth management (каждый CLI сам) +- Session persistence (каждый CLI сам) +- Stall/timeout detection (caller responsibility) +- Progress reporting (caller responsibility) + +Это всё валидная функциональность, но она живёт ВЫШЕ адаптера, в orchestration layer (TeamProvisioningService или его аналог). diff --git a/docs/research/multi-agent-communication-tools.md b/docs/research/multi-agent-communication-tools.md new file mode 100644 index 00000000..cae9ded5 --- /dev/null +++ b/docs/research/multi-agent-communication-tools.md @@ -0,0 +1,542 @@ +# Multi-Agent CLI Orchestrators with Inter-Agent Communication + +> Research date: 2026-03-25 +> Focus: tools where Agent A (Claude) can send a message to Agent B (Codex/Gemini), NOT just "fan-out same task to multiple agents" + +## TL;DR + +Ни один инструмент не является зрелым "фундаментом" для замены нашего стека. Все проекты в этом пространстве молоды (< 6 месяцев), быстро меняют API, и ни один не имеет production-grade inter-agent communication для РАЗНЫХ провайдеров CLI-агентов уровня, который мы уже реализовали для Claude Code Agent Teams. + +**Лидеры по inter-agent communication:** + +| Tool | Stars | Inter-Agent Msg | Multi-Provider | Kanban | Наша оценка | +|------|-------|----------------|----------------|--------|------------| +| Ruflo | 25,709 | SQLite + JSON | Claude + Codex | Нет | Hype-driven, раздутые цифры | +| Composio AO | 5,390 | CI feedback routing | Claude, Codex, Aider | Нет | Planner-executor, не P2P | +| Claude Octopus | 2,069 | Consensus gate 75% | 8 providers | Нет | Plugin, не orchestrator | +| mcp_agent_mail | 1,842 | MCP + SQLite inbox | Any MCP client | Нет | Протокол, не UI | +| claude_code_bridge | 1,855 | Real-time collab | Claude, Codex, Gemini | Нет | Terminal split-pane | +| Overstory | 1,123 | SQLite mail (WAL) | 11 runtimes | Нет | Closest to real P2P | +| agtx | 693 | Session switching | Claude, Codex, Gemini, OpenCode, Cursor | Kanban-like | Autonomous, но молодой | +| AI Maestro | 556 | AMP protocol | Claude, Codex, any | Kanban! | Multi-machine, но TypeScript mesh | +| parallel-code | 407 | Нет (изоляция) | Claude, Codex, Gemini | Diff viewer | Параллельное, не collaborative | +| CAO (AWS) | 344 | SQLite inbox + MCP | Q CLI, Claude, Codex | Нет | AWS-backed, но ранняя стадия | +| MCO | 249 | Fan-out, не P2P | 5 CLIs | Нет | Dispatch layer, не messaging | +| hcom | 164 | File-based hooks | Claude, Codex, Gemini, OpenCode | Нет | Lightweight, hooks-only | +| MetaSwarm | 148 | Skills-based | Claude, Gemini, Codex | Нет | Self-improving framework | +| CAS | 69 | Через MCP server | Claude Code only | Нет | Claude-only, раннее | +| kodo | 46 | Verification cycle | Claude, Codex, Gemini | Нет | SWE-bench verified | + +--- + +## 1. CAS (Coding Agent System) + +- **Repo:** https://github.com/codingagentsystem/cas +- **Stars:** 69 +- **Language:** Rust +- **License:** MIT +- **Created:** 2026-01-05 + +### Что это +Supervisor + Workers модель для Claude Code. Factory mode оркестрирует несколько Claude Code инстансов в параллельных git worktree. MCP server дает агентам persistent memory, task tracking, rules, skills через SQLite + FTS. + +### Inter-Agent Communication +- Нет прямого inter-agent messaging между агентами +- Communication идет через supervisor (hub-and-spoke) +- Workers не общаются друг с другом напрямую +- Coordinator раздает задачи, workers возвращают результаты + +### Multi-Provider Support +- **ТОЛЬКО Claude Code** — нет поддержки Codex, Gemini, Goose и др. + +### Вердикт +Не подходит как фундамент. Claude-only, маленькое коммьюнити (69 stars), нет inter-agent messaging, нет multi-provider. Persistent memory через MCP server — интересная идея, но не уникальная. + +--- + +## 2. AWS CLI Agent Orchestrator (CAO) + +- **Repo:** https://github.com/awslabs/cli-agent-orchestrator +- **Stars:** 344 +- **Language:** Python +- **License:** Apache 2.0 (AWS) +- **Created:** 2025-07-29 + +### Что это +Иерархическая система оркестрации CLI AI агентов от AWS Labs. Три паттерна: Handoff (синхронный transfer), Assign (async spawn), Send Message (прямая коммуникация). + +### Inter-Agent Communication +- **Send Message** — прямые сообщения между существующими агентами +- **SQLite inbox system** — асинхронная доставка сообщений с FIFO ordering +- **File-watching** — определяет когда terminal idle и доставляет pending messages +- **MCP tools** — `handoff`, `assign`, `send_message` для координации +- **REST API** — cao-server на `localhost:9889` + +### Multi-Provider Support +- Amazon Q CLI, Claude Code, Codex CLI (через провайдер с API key) +- Каждый агент в изолированной tmux сессии + +### Что хорошо +- AWS-backed = стабильная поддержка +- Реальный inter-agent messaging через SQLite inbox +- Profile-based agent isolation +- Cron-like scheduled runs + +### Что плохо +- 344 stars — ранняя стадия +- Зависимость на tmux +- Python-based (не наш стек) +- Нет UI/dashboard + +### Вердикт +Наиболее продуманный подход к inter-agent messaging через SQLite inbox. Но ранняя стадия, нет UI, Python-only. Send Message паттерн — это то, что нам нужно, но реализация привязана к tmux sessions. + +--- + +## 3. Overstory + +- **Repo:** https://github.com/jayminwest/overstory +- **Stars:** 1,123 +- **Language:** TypeScript (Bun) +- **License:** MIT +- **Created:** 2026-02-12 + +### Что это +Превращает coding session в multi-agent team. Workers в git worktree через tmux. SQLite mail system для координации. FIFO merge queue с 4-tier conflict resolution. + +### Inter-Agent Communication +- **SQLite mail system** (WAL mode, ~1-5ms/query) — ключевая фича +- **8 typed protocol messages:** `worker_done`, `merge_ready`, `merged`, `merge_failed`, `escalation`, `health_check`, `dispatch`, `assign` +- **Type-safe API:** `sendProtocol()` и `parsePayload()` +- **Broadcast:** группы `@all`, `@builders` и др. +- **`overstory mail`** CLI: send/check/list/read/reply + +### Multi-Provider Support +- **11 runtime adapters:** Claude Code, Pi, Gemini CLI, Aider, Goose, Amp и др. +- Pluggable `AgentRuntime` interface + +### Что хорошо +- Самый развитый SQLite mail system среди всех инструментов +- Type-safe protocol messages — близко к нашему подходу с inbox files +- 11 runtime adapters — реальная мультипровайдерность +- TypeScript/Bun — совместимый стек + +### Что плохо +- Зависимость на tmux + Bun (не Node/Electron) +- "Compounding error rates, cost amplification, debugging complexity" — сами предупреждают +- Нет UI — всё CLI +- 1,123 stars за 1.5 месяца — быстрый рост, но незрелый + +### Вердикт +Ближайший по архитектуре к нашему подходу (SQLite mail ~ наш inbox system). Протокольные сообщения с типами, broadcast — всё это у нас уже есть. Мог бы быть полезен как reference для protocol design, но не как фундамент. + +--- + +## 4. Composio Agent Orchestrator + +- **Repo:** https://github.com/ComposioHQ/agent-orchestrator +- **Stars:** 5,390 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2026-02-13 + +### Что это +Planner-Executor модель для fleet of parallel coding agents. Orchestrator — сам AI agent который читает codebase, decompose features, мониторит progress. Plugin system с 8 swappable slots. + +### Inter-Agent Communication +- **НЕ peer-to-peer messaging** — orchestrator agent роутит feedback +- CI failures → injection back в agent session +- Review comments → routing в правильный agent с контекстом +- Self-improvement loop: logs → retrospectives → adjustments + +### Multi-Provider Support +- Claude Code, Codex, Aider +- Runtime-agnostic: tmux, Docker +- Tracker-agnostic: GitHub, Linear + +### Что хорошо +- 5,390 stars — самый популярный в категории +- TypeScript — наш стек +- Self-improvement system — уникальная фича +- Plugin architecture — гибко + +### Что плохо +- Нет P2P inter-agent messaging — всё через orchestrator +- Agent A не может напрямую послать сообщение Agent B +- Orchestrator = single point of failure +- 1.5 месяца от creation — очень молодой + +### Вердикт +Самый popular, но inter-agent communication = feedback routing через orchestrator, а не direct messaging. Это принципиально другой паттерн, чем наш. Полезен как reference для planner-executor, но не для P2P communication. + +--- + +## 5. hcom (Hook-Comms) + +- **Repo:** https://github.com/aannoo/hcom +- **Stars:** 164 +- **Language:** Rust +- **Created:** 2025-07-21 + +### Что это +Lightweight CLI для inter-agent messaging через hooks. Agents могут message, watch, spawn друг друга across terminals. + +### Inter-Agent Communication +- **`send`** — отправка сообщений между agents +- **`listen`** — блокирующее ожидание с фильтрами (agent, type, status, sender, intent) +- **`events`** — event stream с подписками +- **`bundle`** — structured context packages для handoffs +- **`transcript`** — чтение conversation другого агента +- **TUI dashboard** для мониторинга + +### Multi-Provider Support +- Claude Code, Gemini CLI, Codex, OpenCode +- Hooks integration для Gemini CLI + +### Что хорошо +- Минимальный, специализированный tool для inter-agent messaging +- Работает с любым CLI agent через hooks +- `listen` с фильтрами — мощный примитив + +### Что плохо +- 164 stars — маленькое коммьюнити +- Rust — другой стек +- Нет task management, нет orchestration — только messaging +- Зависимость на hooks mechanism + +### Вердикт +Интересный lightweight подход к messaging, но это only messaging layer без orchestration. Можно изучить как reference для protocol design, но не как фундамент. + +--- + +## 6. AI Maestro + +- **Repo:** https://github.com/23blocks-OS/ai-maestro +- **Stars:** 556 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2025-10-10 + +### Что это +Dashboard для управления агентами across multiple machines. Agent Messaging Protocol (AMP). Skills system. Code Graph. Memory. + +### Inter-Agent Communication +- **Agent Messaging Protocol (AMP)** — email-like communication + - Priority levels, message types, cryptographic signatures, push notifications + - Отдельный open-source протокол: https://github.com/agentmessaging/protocol +- **Peer mesh network** — multi-machine без central server +- **External gateways:** Slack, Discord, Email, WhatsApp + +### Multi-Provider Support +- Claude Code, Aider, Cursor, Copilot, OpenCode, Codex CLI, Gemini CLI +- 30+ compatible agents через Skills + +### Kanban Board +- **ДА!** Полный Kanban с drag-and-drop, dependencies, 5 status columns +- Teams + War Rooms + +### Что хорошо +- **Kanban board** — единственный конкурент с Kanban! +- AMP protocol — formalized inter-agent messaging +- Multi-machine support — уникально +- TypeScript — наш стек +- External messaging gateways + +### Что плохо +- 556 stars — умеренная популярность +- AMP protocol ещё развивается +- tmux dependency +- "80+ agents across multiple computers" — выглядит как over-engineering + +### Вердикт +**Самый близкий конкурент** по feature set: Kanban + inter-agent messaging + multi-provider + TypeScript. AMP protocol — интересный formalized подход. Стоит внимательно изучить. Однако peer mesh network и multi-machine — это другой масштаб, чем наш local-first подход. + +--- + +## 7. ORCH + +- **Website:** https://www.orch.one/ +- **Stars:** N/A (repo не найден / приватный на момент исследования) +- **License:** MIT + +### Что это +CLI runtime для управления Claude Code, Codex, Cursor как typed agent teams. State machine, event bus, TUI. + +### Inter-Agent Communication +- **Typed event bus** — 31 event type, agents emit events, orchestrator reacts +- **Inter-agent messaging** — direct messages, broadcasts, injected в prompts +- **Agent Teams** — group agents under lead, broadcast context +- **State machine:** todo -> in_progress -> review -> done + +### Multi-Provider Support +- 5 adapters: Claude, OpenCode (Gemini, DeepSeek via OpenRouter), Codex, Cursor, Shell + +### Что хорошо +- Event bus architecture — decoupled communication +- State machine — production-quality +- 5 adapters из коробки +- Headless daemon mode (`orch serve`) + +### Что плохо +- GitHub repo не найден или приватный — нельзя оценить реальный код +- Event bus = centralized, не P2P +- Нет UI кроме TUI + +### Вердикт +Архитектурно интересный (event bus + state machine), но невозможно оценить зрелость кода без доступа к repo. Event bus — это скорее pub/sub, чем direct messaging. + +--- + +## 8. Ruflo + +- **Repo:** https://github.com/ruvnet/ruflo +- **Stars:** 25,709 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2025-06-02 + +### Что это +"The leading agent orchestration platform for Claude." Multi-agent swarms, autonomous workflows, RAG integration. Ранее Claude-Flow. + +### Inter-Agent Communication +- SQLite для memory persistence +- JSON-based coordination protocols для inter-agent messaging +- Compaction lifecycle → archive context to SQLite + +### Multi-Provider Support +- Claude Code + Codex integration + +### Что хорошо +- 25K stars — самый популярный в нише +- Comprehensive feature set + +### Что плохо +- 25K stars за < 10 месяцев — подозрительно (возможен бот-boost) +- "v3 introduces self-learning neural capabilities" — marketing buzzwords +- Сравнения с конкурентами в README — red flag +- Claude-centric, minimal real multi-provider + +### Вердикт +Hype-driven проект с подозрительно высокими stars. Inter-agent communication через SQLite + JSON — базовый уровень. Не стоит использовать как фундамент из-за quality concerns. + +--- + +## 9. MCO (Multi-CLI Orchestrator) + +- **Repo:** https://github.com/mco-org/mco +- **Stars:** 249 +- **Language:** Python +- **Created:** 2026-02-26 + +### Что это +Neutral dispatch layer. Отправляет prompts на несколько CLI agents параллельно, агрегирует результаты. + +### Inter-Agent Communication +- **НЕТ real inter-agent messaging** +- Fan-out same prompt → collect results → aggregate +- Structured code review с findings schema + +### Multi-Provider Support +- Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code + +### Вердикт +Dispatch/aggregation, не collaboration. Agent A не знает о Agent B. Полезен для multi-perspective review, но это не inter-agent communication. + +--- + +## 10. mcp_agent_mail + +- **Repo:** https://github.com/Dicklesworthstone/mcp_agent_mail +- **Stars:** 1,842 +- **Language:** Python +- **Created:** 2025-10-23 + +### Что это +Mail-like coordination layer для coding agents. FastMCP server + Git + SQLite. + +### Inter-Agent Communication +- **Inbox/outbox** per agent +- **Searchable message history** +- **File lease system** — voluntary file reservation +- **Memorable identities** для agents +- HTTP-only FastMCP server + +### Multi-Provider Support +- Any MCP-compatible client + +### Что хорошо +- 1,842 stars — солидное коммьюнити +- Clean abstraction: mail metaphor для agent communication +- File leases — unique feature для conflict prevention + +### Что плохо +- Python + FastMCP — другой стек +- Только communication layer, не orchestrator +- Нет task management, нет UI + +### Вердикт +Лучший standalone inter-agent communication protocol. File leases — интересная идея для нас. Но это protocol library, не ready-to-use tool. + +--- + +## 11. agtx + +- **Repo:** https://github.com/fynnfluegge/agtx +- **Stars:** 693 +- **Language:** Rust +- **Created:** 2026-02-08 + +### Что это +Multi-session AI coding terminal manager. Autonomous orchestration с spec-driven workflow. + +### Inter-Agent Communication +- Session switching с context awareness +- Gemini -> research | Claude -> implement | Codex -> review +- Kanban board в TUI + +### Multi-Provider Support +- Claude, Codex, Gemini, OpenCode, Cursor + +### Вердикт +Autonomous orchestration с role-based agent dispatch. Kanban-like TUI. Но Rust стек и нет rich inter-agent messaging. + +--- + +## 12. claude_code_bridge (ccb) + +- **Repo:** https://github.com/bfly123/claude_code_bridge +- **Stars:** 1,855 +- **Language:** Python +- **Created:** 2025-10-25 + +### Что это +Real-time multi-AI collaboration. Split-pane terminal. Persistent context. + +### Inter-Agent Communication +- Real-time collaboration между Claude, Codex, Gemini +- Persistent context sharing +- WYSIWYG split-pane terminal + +### Вердикт +Terminal-based collaboration, не programmatic API. Интересен как UX reference, но не как foundation. + +--- + +## 13. Claude Octopus + +- **Repo:** https://github.com/nyldn/claude-octopus +- **Stars:** 2,069 +- **Language:** Shell +- **Created:** 2026-01-15 + +### Что это +Multi-LLM orchestration plugin для Claude Code. 8 providers, consensus gates. + +### Inter-Agent Communication +- 75% consensus gate — providers должны согласиться +- Parallel (research), sequential (problem scoping), adversarial (review) modes + +### Multi-Provider Support +- Codex, Gemini, Claude, Perplexity, OpenRouter, Copilot, Qwen, Ollama + +### Вердикт +Plugin для Claude Code, не standalone orchestrator. Consensus mechanism — интересно, но это не direct messaging. + +--- + +## Сравнительная таблица: типы Inter-Agent Communication + +| Pattern | Tools | Описание | +|---------|-------|----------| +| **SQLite Inbox/Mail** | CAO, Overstory, mcp_agent_mail | Асинхронная доставка через SQLite, FIFO, typed messages | +| **Event Bus** | ORCH | Typed events, pub/sub, decoupled | +| **AMP Protocol** | AI Maestro | Email-like, priorities, crypto signatures, mesh network | +| **Hooks/File-based** | hcom | File watches + hooks для inter-terminal messaging | +| **Orchestrator Routing** | Composio AO | Central agent роутит feedback, не P2P | +| **Fan-out/Aggregate** | MCO, Claude Octopus | Dispatch same task, collect results — не communication | +| **Session Switching** | agtx, ccb | Context handoff между sessions — implicit communication | + +--- + +## Ключевые выводы + +### 1. Kanban есть ТОЛЬКО у AI Maestro +Из всех исследованных инструментов, только AI Maestro (556 stars) имеет полноценный Kanban board с drag-and-drop. Это подтверждает нашу уникальность. Также agtx имеет kanban-like TUI, но без GUI. + +### 2. Реальный P2P inter-agent messaging — редкость +Большинство инструментов используют hub-and-spoke (orchestrator в центре). Реальный P2P: +- **Overstory** — SQLite mail с typed protocol +- **CAO** — SQLite inbox + Send Message +- **AI Maestro** — AMP protocol + mesh +- **hcom** — hooks-based messaging +- **mcp_agent_mail** — MCP inbox/outbox + +### 3. Ни один инструмент не является зрелым фундаментом +- Все проекты < 6 месяцев (кроме Ruflo и CAO) +- API быстро меняются +- Большинство зависят на tmux +- Нет production-grade error handling + +### 4. Наш подход (Claude Code Agent Teams + Electron UI) остается уникальным +- **Inbox-based messaging** через файлы — мы уже реализовали +- **Kanban board** — мы единственные с полноценным GUI +- **Electron app** — никто больше не делает desktop app для agent orchestration (кроме parallel-code) +- **Team lifecycle management** — наш уровень detail (config.json, session management, DM) не имеет аналогов + +### 5. Что стоит изучить/заимствовать + +| Идея | Источник | Применимость для нас | +|------|----------|---------------------| +| SQLite mail protocol messages (8 types) | Overstory | Можно формализовать наши inbox message types | +| File leases для conflict prevention | mcp_agent_mail | Полезно для multi-agent file editing | +| AMP protocol (priorities, signatures) | AI Maestro | Можно добавить priorities в наш inbox | +| Event bus architecture | ORCH | Для decoupled communication в Electron | +| Self-improvement loop | Composio AO | Agent learning from past sessions | +| Consensus gates | Claude Octopus | Multi-provider code review | +| Pluggable AgentRuntime interface | Overstory | Для будущей multi-provider поддержки | + +--- + +## Рекомендация + +**НЕ использовать ни один из этих инструментов как фундамент.** Причины: + +1. **Наш стек уникален** (Electron + React + TypeScript + Zustand) — ни один tool не совместим +2. **Наша архитектура inbox messaging уже работает** и протестирована +3. **Kanban board** — наше ключевое преимущество, которого нет у конкурентов +4. **Зрелость кода** у всех инструментов низкая (< 6 месяцев) +5. **Dependency risk** — tmux, Bun, Python, Rust — чужой стек + +**Что имеет смысл:** +- Изучить **Overstory** как reference для typed protocol messages +- Изучить **mcp_agent_mail** для file lease механизма +- Изучить **AI Maestro** как ближайшего конкурента (Kanban + AMP) +- Следить за **CAO (AWS)** — AWS backing значит долгосрочную поддержку +- Рассмотреть **AgentRuntime interface** из Overstory для будущей multi-provider поддержки + +--- + +## Источники + +- [CAS - codingagentsystem/cas](https://github.com/codingagentsystem/cas) +- [CAS Website](https://cas.dev/) +- [AWS CLI Agent Orchestrator](https://github.com/awslabs/cli-agent-orchestrator) +- [AWS Blog - Introducing CAO](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) +- [CAO Message Queueing - DeepWiki](https://deepwiki.com/awslabs/cli-agent-orchestrator/3.4-message-queueing-and-inbox-system) +- [Overstory](https://github.com/jayminwest/overstory) +- [Composio Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) +- [hcom](https://github.com/aannoo/hcom) +- [AI Maestro](https://github.com/23blocks-OS/ai-maestro) +- [AMP Protocol](https://github.com/agentmessaging/protocol) +- [ORCH](https://www.orch.one/) +- [MCO](https://github.com/mco-org/mco) +- [Ruflo](https://github.com/ruvnet/ruflo) +- [mcp_agent_mail](https://github.com/Dicklesworthstone/mcp_agent_mail) +- [agtx](https://github.com/fynnfluegge/agtx) +- [claude_code_bridge](https://github.com/bfly123/claude_code_bridge) +- [Claude Octopus](https://github.com/nyldn/claude-octopus) +- [parallel-code](https://github.com/johannesjo/parallel-code) +- [MetaSwarm](https://github.com/dsifry/metaswarm) +- [kodo](https://github.com/ikamensh/kodo) +- [Awesome Agent Orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [Zed Editor - External Agents / ACP](https://zed.dev/docs/ai/external-agents) diff --git a/docs/research/opencode-deep-dive.md b/docs/research/opencode-deep-dive.md new file mode 100644 index 00000000..4bdeb4c4 --- /dev/null +++ b/docs/research/opencode-deep-dive.md @@ -0,0 +1,480 @@ +# OpenCode Deep Dive — Comprehensive Analysis (March 2026) + +## Executive Summary + +OpenCode — open-source AI coding agent от Anomaly (ex-SST), ~126K GitHub stars, 800+ контрибьюторов, 5M MAU. Написан на TypeScript (Bun) + Go (TUI), MIT license. Поддерживает 75+ LLM-провайдеров через Models.dev. Архитектура client/server с persistent sessions. Agent Teams — community-implemented (не core), file-based JSONL inbox, peer-to-peer messaging, multi-provider teams (Claude+Codex+Gemini доказано). Главный конкурент Claude Code в terminal-agent пространстве. + +**Claim verification:** +- "95K+ stars" — **занижено**, на март 2026 ~126-129K stars +- "75+ providers" — **подтверждено**, через Models.dev + AI SDK +- "Multi-agent team support" — **частично**: agent teams реализованы community (opencode-ensemble plugin + PR #12730-12732), НЕ core feature, но доказали работу Claude+Codex+Gemini вместе + +--- + +## 1. What IS OpenCode? + +### Основные факты + +| Параметр | Значение | +|----------|----------| +| **Название** | OpenCode | +| **Организация** | Anomaly (ex-SST / Serverless Stack) | +| **GitHub** | [anomalyco/opencode](https://github.com/anomalyco/opencode) | +| **Сайт** | [opencode.ai](https://opencode.ai/) | +| **Stars** | ~126-129K (март 2026) | +| **Contributors** | 800+ | +| **Commits** | 10,000+ | +| **MAU** | 5M+ developers | +| **License** | MIT | +| **Языки** | TypeScript (Bun) — backend, Go — TUI, Zig — OpenTUI core | +| **Дата запуска** | 19 июня 2025 | +| **Версии** | Terminal CLI, Desktop App (beta), IDE extensions | + +### Кто создал + +**Founders:** +1. **Jay V (CEO)** — задает стратегию, enterprise sales. Университет Waterloo. +2. **Frank Wang (CTO)** — техническая архитектура. Model-agnostic дизайн с нуля. Университет Waterloo. +3. **Dax Raad** — public face, подкасты, Twitter. Ex-Amazon, ex-Ironbay. Присоединился к SST в 2021. +4. **Adam Elmore** — AWS Hero, indie hacker, AWS FM podcast host. + +**Происхождение:** Jay и Frank создали Anomaly, затем Serverless Stack (SST) — прошли Y Combinator, привлекли инвестиции от основателей PayPal, LinkedIn, Yelp, YouTube. SST набрал 25K stars, стал прибыльным в 2025. Во время SST команда строила terminal-first UIs и даже запустила Terminal — подписку на кофе через терминал ($100K продаж в первый год). + +### Скандальная история: Fork и Split (2025) + +- Оригинальный OpenCode создал **Kujtim Hoxha** на Go с Bubble Tea TUI +- **Charm** (компания, создатель Bubble Tea) приобрела проект, наняла Kujtim +- Dax Raad и Adam Doty (из SST) были major contributors, им не понравился ход +- Обвинения: Charm переписал git history, удалил контрибуции, забанил критиков +- **Результат:** Charm переименовал свою версию в **Crush**, а Dax/Adam сохранили бренд OpenCode под SST (anomalyco) +- Fork полностью переписан с Go на **TypeScript + Bun** для использования Vercel AI SDK + +### Скандал с Anthropic (январь 2026) + +- Ранние версии OpenCode подделывали HTTP-заголовок `claude-code-20250219`, выдавая себя за Claude Code +- 9 января 2026 Anthropic заблокировал сторонние tools от использования Claude OAuth +- 19 февраля 2026 Anthropic обновил Terms of Service, запретив OAuth токены Free/Pro/Max в third-party tools +- OpenCode удалил весь Claude OAuth код в тот же день +- Запустили **OpenCode Zen** (pay-as-you-go gateway) и **OpenCode Black** ($200/мес, enterprise) +- **18,000 новых stars за 2 недели** — controversy привлекла внимание + +--- + +## 2. Поддержка 75+ провайдеров + +**Подтверждено.** OpenCode использует [Models.dev](https://models.dev) + Vercel AI SDK для поддержки 75+ LLM-провайдеров. + +### Ключевые провайдеры + +| Провайдер | Детали | +|-----------|--------| +| OpenAI (GPT, Codex) | API key | +| Anthropic (Claude) | API key (после блокировки OAuth) | +| Google Gemini | API key + Vertex AI | +| AWS Bedrock | IAM credentials | +| Groq | API key | +| Azure OpenAI | Enterprise endpoint | +| OpenRouter | Pre-loaded models | +| Ollama (local) | `opencode --model ollama/qwen2.5-coder:32b` | +| GitHub Copilot | Copilot subscription (Pro+ для некоторых моделей) | +| ChatGPT Plus/Pro | OAuth login | +| Cloudflare AI Gateway | Unified billing, no per-provider keys | +| SAP AI Core | 40+ models, enterprise platform | +| GitLab | Agent Platform (18.8+) | +| Deepseek | API key | +| Local models | Any OpenAI-compatible endpoint | + +### Как это работает + +``` +User → OpenCode → AI SDK → Models.dev → Provider API → LLM Response +``` + +- Models.dev — реестр моделей с метаданными +- AI SDK (от Vercel) — универсальный SDK для вызова разных провайдеров +- `/connect` команда — добавление credentials +- `/models` команда — список доступных моделей +- Config: можно назначить разные модели для разных agent-ролей (plan vs build) + +### Монетизация через провайдеров + +| Tier | Цена | Описание | +|------|-------|----------| +| Free | $0 | BYO API key или local models (Ollama) | +| OpenCode Zen | Pay-per-token | Curated gateway, pass-through pricing | +| OpenCode Black | $200/мес | Enterprise, multi-provider (sold out) | + +--- + +## 3. Agent Teams: Multi-Agent Support + +### Статус: Community-Implemented, NOT Core Feature + +Важное уточнение: Agent Teams в OpenCode — это **community contribution**, а не встроенная core-фича (в отличие от Claude Code). + +- **GitHub Issue [#12661](https://github.com/anomalyco/opencode/issues/12661)** — Feature request для native agent teams +- **PRs #12730-12732** (dev branch) — community implementation (core, tools & routes, TUI) +- **[opencode-ensemble](https://github.com/hueyexe/opencode-ensemble)** — SDK plugin для agent teams +- **[opencode-workspace](https://github.com/kdcokenny/opencode-workspace)** — multi-agent orchestration harness + +### Архитектура Agent Teams (community implementation) + +#### Messaging: Two-Layer System + +``` +Layer 1: Inbox (Source of Truth) + team_inbox///.jsonl + Каждая строка: { id, from, text, timestamp, read } + +Layer 2: Session Injection (Delivery) + Message → injected as synthetic user message → LLM видит и обрабатывает +``` + +**Ключевые отличия от Claude Code:** + +| Аспект | Claude Code | OpenCode | +|--------|-------------|----------| +| Storage | JSON array (O(N) writes) | JSONL append-only (O(1)) | +| Messaging | Polling JSON files | Event-driven auto-wake | +| Communication | Leader-centric routing | Full mesh peer-to-peer | +| Multi-model | Single provider only | Multiple providers per team | +| Process model | 3 backends (in-process, tmux, iTerm2) | Single process | +| State tracking | Implicit | Two-level state machines | + +#### State Machines (Dual) + +**Member Status (5 states):** `ready` → `busy` → `shutdown_requested` → `shutdown` (terminal), `error` +- Guards: `guard: true` (prevents race conditions), `force: true` (crash recovery) + +**Execution Status (10 states):** Fine-grained prompt loop position tracking + +#### Peer-to-Peer Messaging + +Любой teammate может отправить сообщение любому другому по имени — не только через lead. Lead фокусируется на orchestration, а не routing. + +#### Sub-Agent Isolation + +Team tools (`team_create`, `team_spawn`, `team_message`) запрещены для sub-agents через deny rules + tool visibility hiding. Sub-agents — одноразовые workers, их output не должен попадать в coordination channel. + +### Доказано: Claude + Codex + Gemini в одной команде + +**Тест 1: Architecture Drama (3 провайдера)** +- GPT-5.3 Codex + Gemini 2.5 Pro + Claude Sonnet 4 +- Координация через один message bus +- Claiming tasks из shared list +- "Arguing about architecture" через peer-to-peer messaging + +**Тест 2: Super Bowl Prediction (4 Claude Opus)** +- Stats analyst + Betting analyst + Matchup analyst + Injury scout +- Full-mesh topology +- Atomic task claiming под concurrent access + +**Тест 3: NFL Research (2 Gemini)** +- Обнаружена проблема: Gemini генерировал ~50 одинаковых "task complete" сообщений в цикле + +### Ограничения + +- Agent teams пока на dev branch, не в stable release +- Нет multi-caller support в core — субагент не знает, кто с ним говорит (кроме Parent) +- Gemini имеет проблемы с message loop +- Recovery при crash: нет auto-restart, user должен re-engage teammates + +--- + +## 4. Architecture Deep Dive + +### Двухъязычная система + +``` +┌──────────────────────────────────────────────┐ +│ User runs `opencode` │ +│ (single Bun-compiled binary) │ +└──────────────────┬───────────────────────────┘ + │ + ┌────────▼─────────┐ + │ Bun Process │ + │ (TypeScript) │──── HTTP Server (API + SSE events) + │ - LLM calls │ ▲ + │ - Tool exec │ │ OpenAPI SDK + │ - Sessions │ │ (auto-generated by Stainless) + │ - LSP client │ ┌────┴──────┐ + │ - Plugin system │ │ Go TUI │ + │ - MCP client │ │ (Client) │ + └──────────────────┘ └────────────┘ + │ + (Migrating to OpenTUI: + Zig core + React/Solid/Vue) +``` + +### Backend (TypeScript + Bun) + +- **Runtime:** Bun (fast JavaScript runtime) +- **Build:** `bun build .. --compile` — single executable +- **HTTP Server:** API + SSE events для real-time updates +- **Storage:** SQLite для persistent data +- **LLM Communication:** Через Vercel AI SDK +- **Tool Execution:** LLM решает когда вызвать tool, SDK вызывает `execute` функцию +- **LSP Integration:** Отправляет `textDocument/didChange`, получает diagnostics, кормит LLM +- **40+ event types:** Через GlobalBus, доставка через SSE + +### Frontend (Go TUI → OpenTUI) + +- **Текущий:** Go с Bubble Tea framework +- **Мигрирует на:** [OpenTUI](https://github.com/anomalyco/opentui) — Zig core + TypeScript bindings +- **OpenTUI:** React/SolidJS/Vue reconcilers, Bun exclusive (Node/Deno в процессе) +- **Persistent sessions:** Сервер в background, TUI реконнектится после disconnect/sleep + +### Client-Server Protocol + +- **OpenAPI spec** → auto-generated SDK через Stainless +- **3 official SDKs:** TypeScript, Go, Python +- **SSE** для real-time events (40+ event types) +- **Zero dependencies** в SDK + +### Desktop App + +- Beta на macOS, Windows, Linux +- Также есть community [OpenGUI](https://dev.to/akemmanuel/i-built-a-native-desktop-gui-for-opencode-in-4-days-with-ai-p44) — Electron + React + +### IDE Extensions + +VS Code, Cursor, Zed, Windsurf, VSCodium + GitHub и GitLab integrations. + +--- + +## 5. Built-in Tools + +| Tool | Описание | +|------|----------| +| Shell | Выполнение bash команд | +| Edit | Exact string replacement в файлах | +| Write | Создание/перезапись файлов | +| Read | Чтение файлов | +| Grep | Regex поиск по codebase | +| LSP | Code intelligence: definitions, references, hover, call hierarchy | + +### Agents + +| Agent | Доступ | Назначение | +|-------|--------|------------| +| **Build** (default) | Full access | Development work | +| **Plan** | Read-only | Analysis, planning | +| **Review** | Read-only + docs | Code review | +| **Debug** | Bash + Read | Investigation | +| **Docs** | File ops, no shell | Documentation | +| **@general** | Subagent | Complex search/multistep | + +--- + +## 6. MCP Support + +**Полная поддержка MCP как client.** Feature request для MCP server mode ([#3306](https://github.com/sst/opencode/issues/3306)). + +### Типы MCP серверов + +1. **Local MCP Servers** — stdio-based communication, запускаются как local processes +2. **Remote MCP Servers** — HTTP + OAuth 2.0 (Dynamic Client Registration RFC 7591) + +### Конфигурация + +```json +// opencode.json +{ + "mcp": { + "sentry": { + "command": "npx", + "args": ["@sentry/mcp-server"], + "env": { "SENTRY_AUTH_TOKEN": "{env:SENTRY_TOKEN}" } + } + } +} +``` + +- Поддержка `{env:VAR}` и `{file:path}` для секретов +- `enabled: false` для временного отключения +- Auto-OAuth flow для remote servers +- Tools автоматически доступны LLM наряду с built-in tools + +### Предупреждение о Context + +MCP tools добавляют контекст. GitHub MCP server, например, может быстро превысить context limit. Рекомендуется осторожность при выборе MCP серверов. + +--- + +## 7. Plugin System & Extensibility + +### Plugin Sources + +1. **Directory plugins:** `.opencode/plugins/` (project) или `~/.config/opencode/plugins/` (global) +2. **NPM packages:** в opencode.json, auto-install через Bun + +### Hook Types + +| Hook | Описание | +|------|----------| +| `tool.execute.before/after` | Перехват tool calls | +| `session.created/updated` | Session lifecycle | +| `message.*` | Message events | +| `event` | System events (`session.idle`, `session.created`, etc.) | +| `experimental.session.compacting` | Inject context before compaction | +| `chat.message` | Modify messages before LLM | + +### Custom Tools + +Plugin tools можно определить — они доступны LLM наряду с built-in tools. Если имя совпадает с built-in, plugin tool имеет приоритет. + +### Notable Community Plugins + +- **EnvSitter** — блокирует чтение `.env*` файлов +- **Agent Ensemble** — agent teams orchestration +- **Persistent Memory** — self-editable memory blocks (как Letta) +- **Annotation UI** — перехватывает plan mode, открывает browser UI +- **Worktree Isolation** — git worktree per agent + +--- + +## 8. What Can We Learn From It? + +### Архитектурные идеи для Claude Agent Teams UI + +1. **Client/Server разделение** — persistent sessions, reconnect после disconnect. Наш Electron-подход можно дополнить server mode для remote access. + +2. **JSONL append-only inbox** — O(1) writes vs O(N) JSON array. **Мы уже используем JSONL для session files**, но team inbox в Claude Code — JSON array. Можно предложить Anthropic JSONL формат. + +3. **Event-driven vs Polling** — OpenCode использует SSE + event bus вместо file polling. Мы используем file watching с debounce (100ms). Event-driven подход быстрее и чище. + +4. **Peer-to-Peer messaging** — в Claude Code все идет через lead. OpenCode показывает, что full-mesh topology работает. **Мы уже отключили relay для teammate DMs** (см. CLAUDE.md), что близко к peer-to-peer. + +5. **Two-level state machines** — member status (coarse) + execution status (fine). Может улучшить наш UI для отображения состояния agents. + +6. **Plugin system** — hooks для tool.execute, session events, compaction. Потенциал для нашего MCP integration. + +7. **Multi-provider teams** — самая уникальная фича. Claude Code не может этого. Для нашего UI это не актуально (мы визуализируем Claude Code teams), но показывает направление рынка. + +8. **Auto-wake** — когда teammate отправляет сообщение idle agent'у, он автоматически "просыпается". В Claude Code нужен manual re-engage. + +--- + +## 9. Competitor or Integration Partner? + +### Как конкурент нашему продукту + +| Аспект | OpenCode | Claude Agent Teams UI (мы) | +|--------|----------|---------------------------| +| **Что это** | Coding agent | UI для управления agent teams | +| **Kanban** | Нет (только community: opencode-kanban, VibeKanban) | Встроенный kanban board | +| **Code Review** | Нет diff view в TUI | Diff view per task | +| **Team Management** | CLI-based, нет visual management | Visual kanban + real-time status | +| **Notifications** | Нет | Встроенные уведомления | +| **Session Analysis** | Базовый | Deep analysis (bash, reasoning, subagents) | +| **Context Monitoring** | Нет | Token usage по категориям | +| **Direct Messaging** | Через CLI | Visual DM interface | + +**Вывод: OpenCode — НЕ прямой конкурент.** Они coding agent, мы — UI для управления agent teams. OpenCode больше конкурирует с Claude Code CLI, а не с нашим UI. + +### Как потенциальный integration partner + +OpenCode имеет **полноценный SDK** (TypeScript, Go, Python) и **SSE events**. Теоретически мы могли бы: + +1. **Добавить OpenCode backend** — управлять OpenCode sessions через их SDK вместо/параллельно Claude Code +2. **Визуализировать OpenCode teams** — их agent teams используют JSONL inbox, мы могли бы парсить +3. **Multi-agent kanban** — один kanban для Claude Code + OpenCode agents +4. **Cross-provider orchestration** — использовать наш UI для управления mixed teams (Claude через Claude Code, GPT/Gemini через OpenCode) + +**Риски интеграции:** +- OpenCode agent teams — community feature, не stable API +- Совершенно другая архитектура (HTTP SDK vs CLI process management) +- Потребуется значительная работа по адаптации + +--- + +## 10. Unique Features vs Claude Code + +| Feature | OpenCode | Claude Code | +|---------|----------|-------------| +| **Model freedom** | 75+ providers, local models | Only Anthropic | +| **Open source** | MIT license, full source | Closed source | +| **Desktop app** | Beta (macOS/Win/Linux) | Нет | +| **IDE extensions** | VS Code, Cursor, Zed, Windsurf | Нет (только CLI) | +| **Plugin system** | Hooks, custom tools, npm plugins | Hooks (bash-based) | +| **Persistent sessions** | Client/server, reconnect | Нет | +| **Agent types** | Build/Plan/Review/Debug/Docs + custom | One agent + subagents | +| **SDK** | TypeScript/Go/Python, OpenAPI spec | Нет public SDK | +| **LSP integration** | Built-in, feeds diagnostics to LLM | Нет | +| **Agent Teams** | Community (multi-provider!) | Native (single provider) | +| **Context compaction** | Supports plugin hook | Automatic | +| **Pricing** | Free + BYO API key | $20/mo Claude Pro minimum | +| **Accuracy** | Varies by model | SWE-bench Pro 57.5% | +| **Adoption** | 5M MAU, 126K stars | 4% of GitHub commits, 135K/day | + +### Что уникально у OpenCode + +1. **Model agnosticism** — designed from day one, не afterthought +2. **Client/server architecture** — sessions persist, remote control possible +3. **Multi-provider agent teams** — Claude+Codex+Gemini в одной команде +4. **Plugin ecosystem** — rich hook system, npm packages, custom tools +5. **3 official SDKs** — TypeScript, Go, Python +6. **OpenTUI** — собственный TUI framework на Zig + +### Что уникально у Claude Code (и у нас) + +1. **Native agent teams** — core feature, не community plugin +2. **SWE-bench accuracy** — лучшие результаты на бенчмарках +3. **4% GitHub commits** — доминирует в реальном использовании +4. **Stream-json protocol** — надежный IPC для agent coordination +5. **Kanban board** (наш UI) — НИКТО не имеет визуального kanban для agent teams + +--- + +## Summary & Key Takeaways + +### Факты (verified) + +- OpenCode — реальный и крупный проект: ~126-129K stars, 800+ contributors, 5M MAU +- 75+ providers — подтверждено через Models.dev + AI SDK +- MIT license — подтверждено +- Agent teams с multi-provider — доказано (community implementation) +- TypeScript (Bun) + Go + Zig architecture — подтверждено +- MCP client support — полноценный +- Desktop app + IDE extensions — beta, но работает +- Plugin system — rich, с hooks и custom tools + +### Риски и concerns + +- Agent teams — community feature, не stable, на dev branch +- Скандал с Anthropic OAuth — показывает этические вопросы +- Fork controversy — community split может повлиять на долгосрочную стабильность +- Gemini message loop bug — multi-provider teams нестабильны +- OpenCode Black ($200/мес) sold out — бизнес-модель не ясна + +### Relevance для нашего продукта + +- **Прямая конкуренция: НЕТ** — мы UI для team management, они coding agent +- **Косвенная конкуренция: ДА** — community tools (opencode-kanban, VibeKanban) пытаются решить ту же проблему +- **Потенциал интеграции: СРЕДНИЙ** — SDK доступен, но архитектура сильно отличается +- **Наше преимущество сохраняется:** Kanban board для agent teams нет НИ У КОГО, включая OpenCode + +--- + +## Sources + +- [anomalyco/opencode (GitHub)](https://github.com/anomalyco/opencode) +- [opencode.ai](https://opencode.ai/) +- [OpenCode Docs](https://opencode.ai/docs/) +- [Building Agent Teams in OpenCode (DEV Community)](https://dev.to/uenyioha/porting-claude-codes-agent-teams-to-opencode-4hol) +- [How OpenCode went from zero to titan (Dev Genius)](https://blog.devgenius.io/how-opencode-went-from-zero-to-titan-in-eight-months-dcdcd8ff5572) +- [OpenCode background story (TFN)](https://techfundingnews.com/opencode-the-background-story-on-the-most-popular-open-source-coding-agent-in-the-world/) +- [How Coding Agents Actually Work: Inside OpenCode](https://cefboud.com/posts/coding-agents-internals-opencode-deepdive/) +- [OpenCode vs Claude Code (MorphLLM)](https://www.morphllm.com/comparisons/opencode-vs-claude-code) +- [OpenCode vs Claude Code (DataCamp)](https://www.datacamp.com/blog/opencode-vs-claude-code) +- [OpenCode vs Anthropic Legal Controversy](https://www.shareuhack.com/en/posts/opencode-anthropic-legal-controversy-2026) +- [OpenCode MCP Servers docs](https://opencode.ai/docs/mcp-servers/) +- [OpenCode Plugins docs](https://opencode.ai/docs/plugins/) +- [OpenCode Agents docs](https://opencode.ai/docs/agents/) +- [OpenCode Models docs](https://opencode.ai/docs/models/) +- [OpenCode Providers docs](https://opencode.ai/docs/providers/) +- [OpenTUI (GitHub)](https://github.com/anomalyco/opentui) +- [opencode-ensemble (GitHub)](https://github.com/hueyexe/opencode-ensemble) +- [opencode-kanban (GitHub)](https://github.com/qrafty-ai/opencode-kanban) +- [Vibe Kanban](https://vibekanban.com/) +- [awesome-opencode (GitHub)](https://github.com/awesome-opencode/awesome-opencode) diff --git a/docs/research/orchestrator-as-foundation.md b/docs/research/orchestrator-as-foundation.md new file mode 100644 index 00000000..db3bc2e1 --- /dev/null +++ b/docs/research/orchestrator-as-foundation.md @@ -0,0 +1,284 @@ +# Оценка: внешний оркестратор как фундамент вместо собственного agent management + +**Дата**: 2026-03-25 +**Вопрос**: стоит ли взять готовый multi-agent оркестратор и посадить наш Electron UI сверху, вместо того чтобы развивать собственный TeamProvisioningService? + +--- + +## 1. Что мы бы заменяли (наш текущий стек) + +### Собственная инфраструктура + +| Компонент | Файлы | LOC | Что делает | +|-----------|-------|-----|------------| +| `TeamProvisioningService.ts` | 1 | ~8000 | Полный lifecycle команды: создание, запуск, stream-json протокол, preflight, stdin relay, tool approval, stall detection, cross-team messaging | +| `agent-teams-controller/` | ~20 модулей | ~4050 | Kanban store, task management, review workflow, cross-team protocol, runtime helpers, message store, process store | +| Остальные team сервисы | 38 файлов | ~13200 | TeamConfigReader, TeamInboxReader/Writer, TeamTaskReader/Writer, TeamKanbanManager, TeamMcpConfigBuilder, CascadeGuard, CrossTeamService, ReviewApplier, MemberStatsComputer, TeamBackupService и др. | +| `childProcess.ts` | 1 | ~220 | spawnCli/execCli с Windows fallback, process tree kill | +| MCP server tools | 8 файлов | ~500 | taskTools, kanbanTools, reviewTools, messageTools, processTools, runtimeTools, crossTeamTools | +| **ИТОГО** | ~68 файлов | **~26000 LOC** | | + +### Ключевые точки spawn (spawnCli вызовы) + +- `TeamProvisioningService.ts` — 4 точки: create team, launch team, launch member, DM relay +- `CliInstallerService.ts` — install CLI +- `ScheduledTaskExecutor.ts` — scheduled tasks +- `McpHealthDiagnosticsService.ts`, `PluginInstallService.ts`, `McpInstallService.ts` — execCli для MCP/plugin операций + +### Что уникально в нашей реализации + +1. **stream-json протокол** — двусторонний, lead читает stdin, teammates читают inbox +2. **Tool approval system** — перехват tool_use запросов, auto-approve по правилам, UI промпт +3. **Cross-team communication** — structured TaskRef, inbox files, cross-team MCP tools +4. **Kanban + code review** — 5-column board, diff view, approve/request_changes workflow +5. **MCP config builder** — передача `--mcp-config` с наследованием для teammates +6. **SIGKILL-only kill** — предотвращение cleanup CLI, который удаляет team файлы +7. **Context monitoring** — token usage tracking по категориям + +--- + +## 2. Оценка кандидатов + +### 2.1 MCO (mco-org/mco) + +**GitHub**: https://github.com/mco-org/mco +**Stars**: ~249 | **Лицензия**: MIT | **Язык**: TypeScript (CLI) +**npm**: `@tt-a1i/mco` + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — только CLI. Нет programmatic API для import. | +| Inter-agent communication? | Частично — агенты диспатчат задачи через MCO CLI, но нет inbox/messaging системы | +| MCP поддержка? | Да — может работать как MCP server | +| Что бы мы СОХРАНИЛИ? | Всё UI, kanban, review, context tracking | +| Что бы мы ЗАМЕНИЛИ? | Только dispatch логику (4 spawnCli точки), и то частично | +| Effort интеграции | **Высокий** — MCO не даёт API, пришлось бы обёртывать CLI вызовы | +| Риск зависимости | **Средний** — 249 stars, 1 основной автор | + +**Вердикт**: MCO решает другую задачу (dispatch к разным CLI), а не управление командой. У нас уже есть более продвинутая система. +- Надёжность решения: **3/10** +- Уверенность в оценке: **8/10** + +--- + +### 2.2 Overstory (jayminwest/overstory) + +**GitHub**: https://github.com/jayminwest/overstory +**Stars**: ~1100 | **Лицензия**: MIT | **Язык**: TypeScript (Bun-native) +**npm**: `@os-eco/overstory-cli` + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | Частично — есть pluggable AgentRuntime интерфейс, но **ТРЕБУЕТ BUN** (не Node.js/Electron) | +| Inter-agent communication? | **Да** — SQLite mail system, 8 типов сообщений, WAL mode, broadcast | +| MCP поддержка? | Упоминается, но без деталей | +| Зависимости | **Bun v1.0+, tmux, git** — все три обязательны | +| Что бы мы СОХРАНИЛИ? | UI, kanban, review, context tracking, MCP server | +| Что бы мы ЗАМЕНИЛИ? | TeamProvisioningService, inbox system, process management | +| Effort интеграции | **КРИТИЧЕСКИЙ** — Bun runtime несовместим с Electron (Node.js). Потребуется форк или полный переписывание на Node.js | +| Риск зависимости | **Высокий** — 1 автор, Bun lock-in, tmux dependency | + +**Вердикт**: Архитектурно интересен (SQLite mail, pluggable runtimes, watchdog), но **Bun dependency — dealbreaker** для Electron-приложения. tmux dependency тоже проблема (нет на Windows). +- Надёжность решения: **2/10** +- Уверенность в оценке: **9/10** + +--- + +### 2.3 ComposioHQ/agent-orchestrator + +**GitHub**: https://github.com/ComposioHQ/agent-orchestrator +**Stars**: ~5400 | **Лицензия**: MIT | **Язык**: TypeScript (pnpm monorepo) +**npm**: `@composio/ao` (CLI) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **Условно** — monorepo с packages, но core не опубликован как отдельный npm пакет `@composio/ao-core`. Нет документации по programmatic API. | +| Inter-agent communication? | **Нет прямой** — агенты изолированы в worktrees, координация через dashboard/server | +| MCP поддержка? | Не упоминается | +| Зависимости | tmux, Next.js dashboard (порт 3000) | +| Plugin architecture | **Сильная** — 8 swappable slots (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Lifecycle) | +| Что бы мы СОХРАНИЛИ? | Kanban UI, review, context tracking, cross-team messaging, MCP tools | +| Что бы мы ЗАМЕНИЛИ? | Process spawning, workspace isolation | +| Effort интеграции | **Высокий** — нет published API, потребуется fork monorepo, вырезание Next.js dashboard, адаптация под Electron IPC | +| Риск зависимости | **Средний** — 5.4K stars, Composio (коммерческая компания) за спиной, но agent-orchestrator =/= их core business | + +**Вердикт**: Самый продвинутый из кандидатов. Plugin architecture — то что нужно. НО: нет published programmatic API, нет inter-agent messaging (мы это уже имеем), требует tmux, и dashboard на Next.js конфликтует с нашим Electron. Интеграция = фактически форк. +- Надёжность решения: **4/10** +- Уверенность в оценке: **8/10** + +--- + +### 2.4 MetaSwarm (dsifry/metaswarm) + +**GitHub**: https://github.com/dsifry/metaswarm +**Stars**: ~148 | **Лицензия**: MIT | **Язык**: TypeScript/JS (skills + commands) +**npm**: `metaswarm` (npx installer) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — это framework из skills/commands/hooks, инжектируемый в CLAUDE.md. Не importable. | +| Inter-agent communication? | Через Claude Code Team Mode (нативный) | +| MCP поддержка? | Нет собственной — использует нативный Claude Code | +| Что бы мы ЗАМЕНИЛИ? | Ничего — это workflow methodology, не runtime | +| Effort интеграции | **Неприменимо** — это не backend, это набор CLAUDE.md инструкций и скриптов | +| Риск зависимости | **Высокий** — 148 stars, 2 контрибьютора, последний коммит feb 2026 | + +**Вердикт**: MetaSwarm — это не оркестратор в техническом смысле. Это structured workflow (skills, personas, phases), который инжектируется в prompt. Не подходит как backend. +- Надёжность решения: **1/10** +- Уверенность в оценке: **9/10** + +--- + +### 2.5 ORCH (oxgeneral/ORCH) + +**GitHub**: https://github.com/oxgeneral/ORCH (404 на момент проверки, возможно приватный) +**Website**: https://www.orch.one +**Stars**: Неизвестно | **Лицензия**: MIT | **Язык**: TypeScript +**npm**: `@oxgeneral/orch` (GitHub Packages registry) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — CLI-only (`npm i -g @oxgeneral/orch`). Нет programmatic API. | +| Inter-agent communication? | **Да** — direct messaging + broadcast + shared key-value store | +| MCP поддержка? | Не упоминается | +| State machine | task states: todo → in_progress → review → done | +| Зависимости | git worktrees, TUI (терминал) | +| Что бы мы ЗАМЕНИЛИ? | Process management, state machine, messaging | +| Effort интеграции | **Высокий** — CLI-only, GitHub Packages registry (не стандартный npm), GitHub 404 | +| Риск зависимости | **КРИТИЧЕСКИЙ** — GitHub repo недоступен (404), 1 автор | + +**Вердикт**: Архитектурно интересен (DDD, state machine, 987 тестов), но **GitHub repo 404** — красный флаг. CLI-only без programmatic API. Нельзя рассматривать как зависимость. +- Надёжность решения: **1/10** +- Уверенность в оценке: **7/10** (7 потому что repo недоступен, не можем полноценно проверить) + +--- + +### 2.6 conductor-oss (charannyk06/conductor-oss) + +**GitHub**: https://github.com/charannyk06/conductor-oss +**Stars**: ~14 | **Лицензия**: MIT | **Язык**: Rust (backend) + TypeScript (Next.js frontend) +**Adapters**: 10 (Claude Code, Codex, Gemini, Qwen, Amp, Cursor CLI, OpenCode, Droid, Copilot, CCR) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **Частично** — Rust binary + HTTP API (port 4747). Можно использовать API. | +| Inter-agent communication? | Через orchestrator server | +| MCP поддержка? | **Да** — `mcp-server` команда, stdio transport | +| Dashboard | Next.js (порт 3000) — конфликт с нашим Electron | +| Что бы мы ЗАМЕНИЛИ? | Agent spawning, workspace isolation, adapter layer | +| Что бы мы СОХРАНИЛИ? | Всё UI, kanban, review, messaging, context tracking | +| Effort интеграции | **Средний-Высокий** — HTTP API есть, но Rust binary нужно распространять с Electron, Next.js dashboard лишний | +| Риск зависимости | **КРИТИЧЕСКИЙ** — 14 stars, 1 автор, Rust dependency в TypeScript/Electron проекте | + +**Вердикт**: HTTP API делает интеграцию возможной, 10 адаптеров впечатляют. НО: 14 stars, Rust sidecar binary для Electron — серьёзная сложность в packaging и distribution. Проект слишком молодой. +- Надёжность решения: **2/10** +- Уверенность в оценке: **8/10** + +--- + +## 3. Сводная таблица + +| Оркестратор | Stars | Library? | Inter-agent msg | MCP | Electron-совместим | Effort | Риск | +|-------------|-------|----------|-----------------|-----|-------------------|--------|------| +| **MCO** | 249 | CLI only | Partial | Yes | Partial | High | Medium | +| **Overstory** | 1.1K | Bun only | SQLite mail | Partial | **NO (Bun)** | Critical | High | +| **Composio AO** | 5.4K | No published API | No direct | No | Partial (tmux) | High | Medium | +| **MetaSwarm** | 148 | No (skills) | Native CC | No | N/A | N/A | High | +| **ORCH** | ? | CLI only | Yes | No | No (TUI) | High | **Critical** | +| **conductor-oss** | 14 | HTTP API | Via server | Yes | Partial (Rust) | Medium-High | **Critical** | + +--- + +## 4. Что конкретно нам дал бы внешний оркестратор + +### Потенциальная ценность +1. **Multi-runtime support** — запуск не только Claude Code, но и Codex, Gemini, Aider +2. **Git worktree isolation** — у нас нет, но и не нужен (Claude Code сам управляет файлами) +3. **Adapters pattern** — абстракция спавна разных CLI + +### Что мы УЖЕ имеем и ни один оркестратор не даёт +1. **stream-json bidirectional protocol** — уникальная интеграция с Claude Code Agent Teams +2. **Tool approval UI** — перехват и approve/reject tool calls в реальном времени +3. **Cross-team structured messaging** — TaskRef, zero-width metadata encoding +4. **Kanban с code review** — diff view, approve/request_changes per task +5. **Context monitoring** — 6-category token tracking +6. **MCP server with 7 tool groups** — kanban, tasks, review, messages, processes, runtime, cross-team +7. **Post-compact context recovery** — восстановление инструкций после compaction +8. **SIGKILL team kill protocol** — предотвращение file cleanup +9. **Cascading guard** — предотвращение cascade team deletion + +--- + +## 5. Ключевой вопрос: стоит ли зависеть от внешнего оркестратора? + +### Аргументы ЗА интеграцию +- Multi-runtime support (Codex, Gemini, Aider) без написания адаптеров +- Потенциально меньше кода для поддержки +- Community contributions и bug fixes + +### Аргументы ПРОТИВ (перевешивают) + +1. **Ни один оркестратор не имеет programmatic library API для embedding в Electron** + - Все либо CLI-only, либо CLI + собственный dashboard + - Интеграция = обёртка над CLI вызовами или fork — то есть по сути мы сами пишем адаптер + +2. **Наша интеграция глубже любого оркестратора** + - stream-json протокол, tool approval, cross-team refs — этого нет НИ У КОГО + - Мы бы потеряли эти фичи при переходе на внешний backend + +3. **Несовместимость со стеком** + - Bun (Overstory) vs Node.js/Electron + - Rust sidecar (conductor-oss) — packaging nightmare + - tmux (Composio, Overstory) — нет на Windows + - Next.js dashboards — дублирование с нашим Electron UI + +4. **Стоимость интеграции >= стоимость написания адаптера** + - Даже для лучшего кандидата (Composio AO) нужен fork monorepo + выпиливание Next.js + адаптация под IPC + - Это ~2-4 недели работы с непредсказуемым результатом + - Наш собственный adapter layer для нового CLI = ~200-500 LOC (1-2 дня) + +5. **Риск зависимости** + - Большинство проектов < 6 месяцев, 1-2 автора + - Composio AO (5.4K stars) — самый живой, но agent-orchestrator != core business Composio + - Если проект умирает — мы на форке без community + +--- + +## 6. Рекомендация + +### Вердикт: **НЕ ИСПОЛЬЗОВАТЬ внешний оркестратор как фундамент** + +Стоимость интеграции выше, чем написание собственного тонкого adapter layer. + +### Что стоит ЗАИМСТВОВАТЬ (паттерны, не код) + +| Паттерн | Источник | Применение у нас | +|---------|----------|-----------------| +| Plugin architecture (8 slots) | Composio AO | Вынести agent adapter в интерфейс `AgentRuntime` для будущей multi-runtime поддержки | +| SQLite mail system | Overstory | Рассмотреть для замены JSON inbox files (производительность) | +| State machine для tasks | ORCH | У нас уже есть kanban states, но можно формализовать transitions | +| AgentRuntime interface | Overstory | `{ spawn, configure, detectReadiness, parseTranscript }` — хороший контракт | +| Tiered watchdog | Overstory | Stall detection → AI triage → monitor agent | + +### Рекомендуемый план + +1. **Сейчас**: оставить текущую архитектуру, она работает и покрывает наш use case +2. **Если нужен multi-runtime**: написать `AgentRuntime` интерфейс (~200 LOC) + адаптер для каждого CLI (~300-500 LOC) +3. **Если нужна масштабируемость messaging**: рассмотреть миграцию с JSON inbox → SQLite WAL +4. **Мониторить** Composio AO — если опубликуют `@composio/ao-core` как npm library с programmatic API, пересмотреть решение + +- **Надёжность рекомендации**: 8/10 +- **Уверенность**: 9/10 + +--- + +## Источники + +- [MCO (mco-org/mco)](https://github.com/mco-org/mco) — 249 stars, CLI-only orchestrator +- [Overstory (jayminwest/overstory)](https://github.com/jayminwest/overstory) — 1.1K stars, Bun + SQLite + tmux +- [Composio Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) — 5.4K stars, plugin architecture +- [MetaSwarm (dsifry/metaswarm)](https://github.com/dsifry/metaswarm) — 148 stars, workflow framework +- [ORCH (orch.one)](https://www.orch.one/) — CLI runtime, GitHub repo 404 +- [conductor-oss (markdown-native)](https://github.com/charannyk06/conductor-oss) — 14 stars, Rust + 10 adapters +- [Awesome Agent Orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) — каталог 80+ инструментов +- [Composio Architecture Design](https://github.com/ComposioHQ/agent-orchestrator/blob/main/artifacts/architecture-design.md) diff --git a/docs/research/sdk-vs-cli-comparison.md b/docs/research/sdk-vs-cli-comparison.md new file mode 100644 index 00000000..5a0e8020 --- /dev/null +++ b/docs/research/sdk-vs-cli-comparison.md @@ -0,0 +1,376 @@ +# SDK vs CLI Direct Spawn: Honest Comparison + +**Date:** 2026-03-25 +**Status:** Research complete +**Verdict:** SDKs are NOT limiting — they ARE the CLI with a nicer API, plus extras. But there are real tradeoffs. + +--- + +## TL;DR + +All three SDKs (Claude Agent SDK, Codex SDK, Gemini CLI SDK) **spawn the CLI as a child process** under the hood and communicate via stdin/stdout JSON protocol. The SDK IS the CLI — it just wraps `child_process.spawn()` with a typed API. There is **no functional limitation** vs direct spawn, because the SDK literally does the same thing. However, there is a **real performance overhead** (~12s per `query()` call for Claude) and some **CLI-only features** (Agent Teams for Claude) that require workarounds. + +--- + +## 1. Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) + +### Architecture: How It Works Under the Hood + +The SDK **bundles `cli.js`** directly inside the npm package. When you call `query()`, it spawns a Node.js process running this bundled CLI with `--input-format stream-json --output-format stream-json --verbose`. Communication is via NDJSON over stdin/stdout. + +> "The SDK code actually bundles a cli.js file directly — which contains the entire Claude Code CLI." +> — [Claude Agent SDK Pitfalls](https://liruifengv.com/posts/claude-agent-sdk-pitfalls-en/) + +**Key insight:** The `spawnClaudeCodeProcess` option lets you provide a completely custom spawn function. Node's `ChildProcess` already satisfies the `SpawnedProcess` interface. This means you can override HOW the CLI is spawned — Docker, VM, remote, whatever. + +### Complete Options Reference (from [official docs](https://platform.claude.com/docs/en/agent-sdk/typescript)) + +| Option | Type | Description | +|--------|------|-------------| +| `model` | `string` | Claude model to use | +| `cwd` | `string` | Working directory | +| `env` | `Record` | Environment variables | +| `systemPrompt` | `string \| preset` | Custom or `claude_code` preset | +| `allowedTools` | `string[]` | Auto-approve tools | +| `disallowedTools` | `string[]` | Deny tools (checked first, overrides everything) | +| `mcpServers` | `Record` | MCP server configs (stdio, SSE, HTTP, in-process SDK) | +| `strictMcpConfig` | `boolean` | Only use MCP servers from this config | +| `settingSources` | `SettingSource[]` | `["user", "project", "local"]` to match CLI behavior | +| `permissionMode` | `PermissionMode` | `default`, `acceptEdits`, `bypassPermissions`, `plan`, `dontAsk` | +| `canUseTool` | `Function` | Custom permission callback | +| `agents` | `Record` | Programmatic subagents | +| `hooks` | `Partial>` | Programmatic hook callbacks | +| `plugins` | `SdkPluginConfig[]` | Load custom plugins | +| `maxTurns` | `number` | Max agentic turns | +| `maxBudgetUsd` | `number` | Budget cap | +| `effort` | `'low'\|'medium'\|'high'\|'max'` | Thinking depth | +| `thinking` | `ThinkingConfig` | Adaptive thinking config | +| `betas` | `SdkBeta[]` | Beta features (e.g., `context-1m-2025-08-07`) | +| `includePartialMessages` | `boolean` | Stream partial responses | +| `outputFormat` | `{ type: 'json_schema', schema }` | Structured output | +| `spawnClaudeCodeProcess` | `Function` | Custom spawn function | +| `pathToClaudeCodeExecutable` | `string` | Custom CLI path | +| `executable` | `'bun'\|'deno'\|'node'` | JS runtime | +| `executableArgs` | `string[]` | Runtime args | +| **`extraArgs`** | **`Record`** | **ANY arbitrary CLI flags** | +| `debug` | `boolean` | Debug mode | +| `debugFile` | `string` | Debug log file | +| `sandbox` | `SandboxSettings` | Sandbox config | +| `persistSession` | `boolean` | Disable session persistence | +| `resume` | `string` | Resume session by ID | +| `forkSession` | `boolean` | Fork on resume | +| `enableFileCheckpointing` | `boolean` | File change tracking | +| `fallbackModel` | `string` | Fallback model | +| `promptSuggestions` | `boolean` | Emit prompt suggestions | +| `stderr` | `Function` | Stderr callback | + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP config | `--mcp-config '{...}'` | `mcpServers: {...}` | SDK has typed config + in-process MCP servers (SDK advantage) | +| Strict MCP | `--strict-mcp-config` | `strictMcpConfig: true` | Equivalent | +| Disallowed tools | `--disallowedTools X,Y` | `disallowedTools: ['X','Y']` | Equivalent. Known bug: both ignore MCP tools in `-p` mode ([#12863](https://github.com/anthropics/claude-code/issues/12863)) | +| Allowed tools | `--allowedTools X,Y` | `allowedTools: ['X','Y']` | Equivalent | +| stream-json I/O | `--input-format stream-json --output-format stream-json` | Automatic (SDK default) | SDK uses this internally, no config needed | +| Permission mode | `--permission-mode X` | `permissionMode: 'X'` | Equivalent | +| Custom flags | Any `--flag value` | `extraArgs: { flag: 'value' }` | **SDK supports arbitrary flags via `extraArgs`** | +| CLAUDE.md | Auto-loaded | `settingSources: ['project']` | Opt-in in SDK, auto in CLI | +| Custom spawn | Manual `child_process.spawn()` | `spawnClaudeCodeProcess: (opts) => spawn(...)` | SDK provides typed interface | +| In-process MCP | Not possible | `createSdkMcpServer()` | **SDK-only advantage** — no subprocess overhead | +| Custom tools | Via MCP only | In-process functions | **SDK-only advantage** | +| Programmatic hooks | Via config files | Callback functions | **SDK-only advantage** | +| Programmatic subagents | Via config files | `agents: {...}` inline | **SDK-only advantage** | +| Agent Teams | Full support | **CLI-only feature** | Not configurable via SDK options. Must use CLI | +| Auto memory | Full support | **Never loaded by SDK** | CLI-only feature | +| Skills | Full support | Via `settingSources` + `allowedTools: ['Skill']` | Equivalent when configured | +| Session resume | `claude --resume ID` | `resume: 'sessionId'` | Equivalent | +| Streaming | Via flags | `includePartialMessages: true` | SDK provides typed events | +| Structured output | Not available | `outputFormat: { type: 'json_schema', ... }` | **SDK-only advantage** | +| File checkpointing | Not available | `enableFileCheckpointing: true` | **SDK-only advantage** | +| V2 Session API | Not available | `unstable_v2_*` | **SDK-only**, unstable | + +### Performance: ~12s Overhead Per `query()` Call + +**This is real and documented.** Each `query()` call spawns a new CLI process, which takes ~12s to initialize. + +> "The Claude Agent SDK `query()` has ~12s overhead per call — no hot process reuse" +> — [GitHub Issue #34](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34) + +For comparison, direct Anthropic Messages API: 1-3s. Previous SDK versions: ~40s (improved 70%). + +**But for our use case (long-running agent sessions), this doesn't matter.** We spawn teams that run for minutes/hours. 12s startup is amortized. If you need sub-second responses, use the Anthropic API directly — not the SDK and not CLI direct. + +**Direct CLI spawn has the SAME overhead** — the 12s is the CLI initialization time, not SDK overhead. SDK adds negligible wrapper cost on top. + +### SDK-Only Features (Not Available in CLI Direct) + +1. **In-process MCP servers** — `createSdkMcpServer()`, no subprocess management +2. **Custom tools as functions** — No separate MCP server needed +3. **Programmatic hooks** — TypeScript/Python callbacks, not shell scripts +4. **Structured output** — JSON schema for typed responses +5. **File checkpointing** — Rewind file changes to any point +6. **Typed message stream** — `SDKMessage` union type with discriminators +7. **Dynamic MCP management** — `setMcpServers()`, `toggleMcpServer()`, `reconnectMcpServer()` +8. **Prompt suggestions** — AI-generated next prompt +9. **Permission callbacks** — `canUseTool()` with structured decisions + +### CLI-Only Features (Not Available in SDK) + +1. **Agent Teams** — Multiple coordinated sessions (our core feature!) +2. **Auto memory** — `~/.claude/projects/*/memory/` persistence +3. **Interactive TUI** — Terminal UI + +### Critical Finding for Our Project + +**Agent Teams are CLI-only.** The official docs explicitly state: +> "Agent teams are a CLI feature where one session acts as the team lead, coordinating work across independent teammates." +> — [Claude Code Features in SDK](https://platform.claude.com/docs/en/agent-sdk/claude-code-features) + +This means for our team management feature, we **MUST** use CLI direct (which we already do). The SDK cannot replace our current architecture for teams. + +However, for solo agents or subagent workflows, the SDK provides a better API. + +--- + +## 2. Codex SDK (`@openai/codex-sdk`) + +### Architecture + +> "The TypeScript SDK wraps the Codex CLI from `@openai/codex`. It spawns the CLI and exchanges JSONL events over stdin/stdout." +> — [Codex SDK docs](https://developers.openai.com/codex/sdk) + +Same pattern as Claude: SDK spawns CLI subprocess. + +### API Surface + +```typescript +const codex = new Codex({ + env?: Record, // Environment variables + baseUrl?: string, // API base URL (→ --config openai_base_url=...) + config?: Record // Arbitrary config (→ --config key=value) +}); + +const thread = codex.startThread({ + workingDirectory?: string, + skipGitRepoCheck?: boolean +}); + +// Buffered +const result = await thread.run(prompt, { outputSchema?: JSONSchema }); + +// Streaming +for await (const event of thread.runStreamed(prompt)) { ... } + +// Resume +const thread = codex.resumeThread(threadId); +``` + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP config | `config.toml` / `codex mcp` | Via `config` option passthrough | CLI manages MCP directly | +| Custom flags | Any flag | `config: { key: value }` → `--config key=value` | Limited to config passthrough | +| Model selection | `/model` command | Not directly exposed | Must use config | +| Approval modes | `--full-auto`, etc. | Not directly exposed | Must use config or env | +| Structured output | Not in interactive mode | `outputSchema` (Zod → JSON Schema) | **SDK advantage** | +| Thread persistence | `codex resume` | `resumeThread(threadId)` | Equivalent | +| Streaming | JSONL stdout | `runStreamed()` async generator | SDK provides typed events | +| Multimodal input | Screenshots, sketches | `{ type: 'local_image', path }` | Equivalent | +| Performance | Baseline (CLI init) | Same + minimal SDK overhead | No significant difference | + +### Native SDK Alternative: `@codex-native/sdk` + +There's a Rust-based alternative via napi-rs that **does NOT spawn child processes**: + +> "The Native SDK provides Rust-powered bindings via napi-rs, giving you direct access to Codex functionality without spawning child processes." +> — [@codex-native/sdk npm](https://www.npmjs.com/package/@codex-native/sdk) + +Full API compatibility with the TypeScript SDK, but with native performance. However, only 33 weekly downloads — practically nobody uses it. + +### Known Issues + +- **Windows spawn EPERM** — CLI fails on Windows ([#7810](https://github.com/openai/codex/issues/7810)) +- **Zombie MCP processes** — 1,300+ zombies, 37GB memory leak ([#12491](https://github.com/openai/codex/issues/12491)) +- **Subagents experimental** — Gated behind `features.multi_agent` flag + +--- + +## 3. Gemini CLI SDK (`@google/gemini-cli-sdk`) + +### Architecture + +Monorepo with three packages: +- `@google/gemini-cli` — Bundled single executable (CLI) +- `@google/gemini-cli-core` — Core logic, API orchestration, tool execution +- `@google/gemini-cli-sdk` — Programmatic API layer over core + +**Key difference from Claude/Codex:** The Gemini SDK **does NOT spawn CLI as subprocess**. It uses `@google/gemini-cli-core` directly as a library. This is architecturally different — the SDK calls core functions in-process. + +### API Surface + +```typescript +// Agent-based API +const agent = new GeminiCliAgent(definition: LocalAgentDefinition); +// Includes: model config, tools, system instructions + +// Session management +const session = new GeminiCliSession(context: AgentLoopContext); + +// Activity monitoring +agent.onActivity((activity) => { ... }); +``` + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP support | `config.toml` | Via core ToolRegistry | Same underlying system | +| Custom tools | Via MCP servers | Via ToolRegistry + custom definitions | SDK has more direct access | +| Model routing | Auto fallback | Via ModelConfig | Same capability | +| Hooks | Shell scripts | Programmatic callbacks | SDK advantage | +| Sandboxing | Built-in | Via SandboxManager | Same capability | +| Output format | `--output-format json/stream-json` | Typed events via callbacks | SDK provides typed events | +| Extensions | Plugin architecture | Same plugin system | Equivalent | +| Agent Skills | Custom skills | Custom skills | Equivalent | +| Performance | Baseline | **No subprocess — in-process** | **SDK is faster** | +| Abort support | Ctrl+C | Limited — aborted requests continue ([known issue](https://github.com/google-gemini/gemini-cli/issues/15539)) | CLI wins here | +| Checkpointing | Automatic snapshots | Via SDK session | Equivalent | + +### Maturity + +The SDK was introduced in v0.30.0. GitHub issue [#15539](https://github.com/google-gemini/gemini-cli/issues/15539) requesting a formal SDK is now **CLOSED as completed**. The core API surface is still evolving — `@google/gemini-cli-core` includes "robust compatibility measures" suggesting instability. + +--- + +## 4. Cross-SDK Comparison Matrix + +| Dimension | Claude Agent SDK | Codex SDK | Gemini CLI SDK | +|-----------|-----------------|-----------|----------------| +| **Architecture** | Spawns CLI subprocess | Spawns CLI subprocess | In-process (uses core directly) | +| **Startup overhead** | ~12s per query() | Unknown (similar pattern) | Minimal (no subprocess) | +| **CLI flag passthrough** | `extraArgs` for ANY flag | `config` for config flags | N/A (not subprocess-based) | +| **MCP support** | Full (stdio, SSE, HTTP, in-process) | Full (stdio, HTTP) | Full (via ToolRegistry) | +| **In-process tools** | `createSdkMcpServer()` | Custom tool registration | ToolRegistry | +| **Structured output** | JSON Schema | JSON Schema (Zod) | Zod schemas | +| **Agent teams** | CLI-only | N/A | N/A | +| **Subagents** | Programmatic + filesystem | Experimental | Via LocalAgentExecutor | +| **Streaming** | AsyncGenerator | AsyncGenerator events | onActivity callback | +| **Custom spawn** | `spawnClaudeCodeProcess` | Not exposed | N/A (no subprocess) | +| **Session resume** | Full (resume, fork) | Full (resumeThread) | Via GeminiCliSession | +| **Hooks** | Programmatic callbacks | Not documented | Programmatic callbacks | +| **License** | Proprietary | Open source (Apache 2.0) | Open source (Apache 2.0) | +| **npm weekly DL** | High (official Anthropic) | High (official OpenAI) | Medium (newer) | +| **Maturity** | Production (v0.2.81) | Production | Early (v0.30.0+) | + +--- + +## 5. Key Questions Answered + +### Q1: Does Claude Agent SDK support ALL CLI flags? + +**YES.** Via `extraArgs: Record` you can pass ANY arbitrary flag. Plus most important flags have dedicated typed options (`mcpServers`, `disallowedTools`, `allowedTools`, `permissionMode`, etc.). + +### Q2: Does Codex SDK expose all CLI capabilities? + +**Partially.** The `config` option can pass arbitrary config values, but not all CLI flags are exposed as typed options. The API surface is minimal compared to Claude's SDK. + +### Q3: Does Gemini CLI SDK expose all CLI capabilities? + +**Mostly.** Since it uses `@google/gemini-cli-core` directly (not subprocess), it has access to all internal APIs. But the public SDK surface is still maturing. + +### Q4: Is there a performance overhead? + +**Claude/Codex: YES — ~12s startup per query() call.** This is the CLI initialization time, not SDK overhead. Direct spawn has the same cost. + +**Gemini: NO additional overhead** — in-process architecture, no subprocess. + +### Q5: Can we pass arbitrary flags through the SDK? + +- **Claude:** YES, via `extraArgs` +- **Codex:** Partially, via `config` (maps to `--config key=value`) +- **Gemini:** N/A (not subprocess-based) + +### Q6: Does the SDK actually spawn the CLI? + +- **Claude:** YES — spawns bundled `cli.js` via `child_process` +- **Codex:** YES — spawns CLI and exchanges JSONL over stdin/stdout +- **Gemini:** NO — uses core library in-process + +### Q7: What happens when a new CLI flag is added? + +- **Claude:** `extraArgs` passes ANY flag through immediately. No SDK update needed. +- **Codex:** May need SDK update for new flags not covered by `config` +- **Gemini:** Core library update needed, but it's the same package ecosystem + +### Q8: Can we use SDK and direct CLI interchangeably? + +**YES.** They are not mutually exclusive. Use SDK for simple flows, CLI direct for advanced (Agent Teams, etc.). Both produce the same session files, use the same auth, same MCP servers. + +--- + +## 6. Verdict for Our Project (Claude Agent Teams UI) + +### What We Need + +1. **Agent Teams** (lead + teammates, stream-json, inbox messaging) — **CLI-only** +2. **MCP config passthrough** (`--mcp-config`, `--strict-mcp-config`) — **Both work** +3. **Disallowed tools** (`--disallowedTools`) — **Both work** +4. **stream-json stdin/stdout** — **SDK uses this internally, CLI direct also works** +5. **Custom spawn control** (Electron, process management) — **Both work** +6. **Long-running sessions** (teams run for hours) — **Both work, 12s overhead irrelevant** + +### Recommendation + +| Use Case | Approach | Confidence | +|----------|----------|------------| +| Agent Teams (lead + teammates) | **CLI direct spawn** (current) | 10/10 — SDK cannot do this | +| Solo agent mode | **Either works**, SDK is nicer | 9/10 | +| Future multi-provider support | **CLI direct for each** | 8/10 — more flexible | +| Subagent orchestration | **SDK preferred** (typed subagents) | 8/10 | + +### Final Assessment + +**The concern about SDKs being "less flexible" is UNFOUNDED for most use cases.** The SDKs provide typed access to everything the CLI does, plus extras (in-process MCP, programmatic hooks, structured output). The `extraArgs` option in Claude SDK means you're never blocked by missing typed options. + +**The concern about SDKs being "slower" is VALID but IRRELEVANT for long-running agents.** The ~12s startup overhead is the CLI itself, not the SDK wrapper. Direct spawn has the same cost. + +**The ONE real limitation: Agent Teams are CLI-only.** Since our core feature IS Agent Teams, we MUST use CLI direct for team management. This is not a limitation of "SDKs in general" — it's a specific architectural decision by Anthropic. + +### Hybrid Approach (Best of Both Worlds) + +``` +Agent Teams → CLI direct spawn (mandatory) +Solo agents → SDK query() (nicer API, typed, in-process MCP) +Subagents → SDK agents option (programmatic, isolated) +Multi-provider → CLI direct for each provider +``` + +Reliability: 9/10 +Confidence: 9/10 + +--- + +## Sources + +- [Claude Agent SDK TypeScript Reference](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Claude Code Features in SDK](https://platform.claude.com/docs/en/agent-sdk/claude-code-features) +- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview) +- [Claude Agent SDK MCP](https://platform.claude.com/docs/en/agent-sdk/mcp) +- [Claude Agent SDK Performance Issue #34](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34) +- [Claude Agent SDK Custom Spawn #103](https://github.com/anthropics/claude-agent-sdk-typescript/issues/103) +- [Claude Agent SDK Pitfalls](https://liruifengv.com/posts/claude-agent-sdk-pitfalls-en/) +- [Claude Agent SDK vs CLI System Prompts](https://github.com/shanraisshan/claude-code-best-practice/blob/main/reports/claude-agent-sdk-vs-cli-system-prompts.md) +- [Claude Code vs Claude Agent SDK (Medium)](https://drlee.io/claude-code-vs-claude-agent-sdk-whats-the-difference-177971c442a9) +- [--disallowedTools MCP bug #12863](https://github.com/anthropics/claude-code/issues/12863) +- [Codex SDK Documentation](https://developers.openai.com/codex/sdk) +- [Codex CLI Documentation](https://developers.openai.com/codex/cli) +- [Codex MCP Support](https://developers.openai.com/codex/mcp) +- [@codex-native/sdk npm](https://www.npmjs.com/package/@codex-native/sdk) +- [Codex Zombie Process Bug #12491](https://github.com/openai/codex/issues/12491) +- [Gemini CLI SDK DeepWiki](https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api) +- [Gemini CLI Formal SDK Request #15539](https://github.com/google-gemini/gemini-cli/issues/15539) +- [Gemini CLI npm Package](https://geminicli.com/docs/npm/) +- [Making Claude Agents Run Faster](https://medium.com/@bayllama/making-your-agents-built-using-claude-agent-sdk-run-faster-2f2526a5cb42) +- [Building Agents with Claude Agent SDK (Anthropic)](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk) diff --git a/docs/research/unified-cli-agent-interface.md b/docs/research/unified-cli-agent-interface.md new file mode 100644 index 00000000..76489a32 --- /dev/null +++ b/docs/research/unified-cli-agent-interface.md @@ -0,0 +1,533 @@ +# Unified CLI Agent Interface — Research (March 2026) + +Research on tools/libraries providing a unified interface for calling multiple AI coding CLI agents abstractly (Claude Code, Codex CLI, Gemini CLI, Goose, OpenCode, Aider, etc.). + +## Summary & Recommendation + +**No single battle-tested npm library exists** that abstracts CLI agent spawning behind a clean TypeScript interface suitable for embedding in an Electron app. The ecosystem is fragmented across ~10 projects, each with tradeoffs. The most relevant options for our use case are: + +| Project | Lang | Approach | Library Use | Agents | Our Fit | +|---------|------|----------|-------------|--------|---------| +| **Coder AgentAPI** | Go | HTTP API over terminal emulation | Via HTTP (language-agnostic) | 11 | 8/10 | +| **all-agents-mcp** | TS | MCP server, child process spawn | npm import or MCP | 4 | 7/10 | +| **Overstory** | TS | AgentRuntime interface + tmux | CLI only (Bun) | 11 | 6/10 | +| **Composio Agent Orchestrator** | TS | Plugin architecture, worktrees | Build from source | 4+ | 5/10 | +| **MCO** | Python | CLI adapter hooks | CLI/MCP only | 5+ | 4/10 | +| **Network-AI** | TS | Blackboard coordination | npm library | 17 adapters* | 3/10 | +| **AWS CAO** | Python | tmux + MCP server | REST API | 7 | 4/10 | + +\* Network-AI adapters are for AI frameworks (LangChain, CrewAI, etc.), not CLI coding agents directly. + +**Recommended approach**: Extract the adapter pattern from Coder AgentAPI or all-agents-mcp, build our own thin `AgentAdapter` interface in TypeScript. + +--- + +## Tier 1 — Most Relevant for Our Use Case + +### 1. Coder AgentAPI + +**The most mature unified interface for controlling CLI coding agents programmatically.** + +- **URL**: https://github.com/coder/agentapi +- **Stars**: ~1,300 +- **Language**: Go (82%), TypeScript (15% — web UI) +- **License**: MIT +- **npm package**: None (Go binary, HTTP API) + +#### How it works +Runs an in-memory terminal emulator (Go). Translates API calls into terminal keystrokes, parses agent output into structured messages. Each agent type has a message formatter in `lib/msgfmt/`. + +#### Supported agents (11) +Claude Code, Goose, Aider, Gemini CLI, GitHub Copilot, AmazonQ, OpenCode, Sourcegraph Amp, Codex, Auggie, Cursor CLI. + +#### API surface +``` +GET /messages — conversation history +POST /message — send message (type: "user" | "raw") +GET /status — "stable" | "running" +GET /events — SSE stream (real-time) +GET /openapi.json — full OpenAPI schema +``` + +#### Integration with Electron +- Spawn `agentapi server --type=claude -- claude` as child process +- Communicate via HTTP (localhost:3284) +- SSE events for real-time status updates +- Can generate TS client from OpenAPI spec using `@hey-api/openapi-ts` +- **Con**: Requires Go binary distribution alongside Electron app +- **Con**: Terminal emulation approach is fragile — keystrokes, not stdin/stdout protocol + +#### Reliability: 7/10 +#### Confidence: 8/10 — well-maintained by Coder (enterprise company), actively updated + +Source: [github.com/coder/agentapi](https://github.com/coder/agentapi) + +--- + +### 2. all-agents-mcp + +**TypeScript MCP server that orchestrates agents via unified stdio interface.** + +- **URL**: https://github.com/Dokkabei97/all-agents-mcp +- **npm**: `all-agents-mcp` +- **Language**: TypeScript (100%) +- **License**: MIT (assumed) + +#### How it works +Invokes each agent's CLI binary as a child process. Each agent implementation extends `BaseAgent` abstract class which handles process spawning, stdin/stdout capture. No API bypass — pure process orchestration. + +#### Key TypeScript interface +``` +src/agents/ + IAgent interface — identity, availability, execution, health + BaseAgent abstract class — spawn logic, stdin/stdout + claude-agent.ts + codex-agent.ts + gemini-agent.ts + copilot-agent.ts +``` + +#### Supported agents (4) +Claude Code, Codex CLI, Gemini CLI, GitHub Copilot CLI. + +#### API surface (MCP tools) +- `ask_agent` — single agent query +- `ask_all` — parallel multi-agent comparison +- `delegate_task` — complexity-based routing +- `cross_verify` — same agent, multiple models +- Plus specialized: code review, debug, explain, test gen, refactor + +#### Integration with Electron +- **Pure TypeScript** — best language fit +- Can import as library or run as MCP server +- Child process spawning maps well to our existing architecture +- `IAgent` interface is close to what we need +- **Con**: Only 4 agents (vs 11 in AgentAPI) +- **Con**: Young project, may lack edge case handling +- **Con**: MCP-first design, not raw process management + +#### Reliability: 5/10 +#### Confidence: 6/10 — concept is solid, but limited agent coverage + +Source: [github.com/Dokkabei97/all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) + +--- + +### 3. Overstory + +**Multi-agent orchestration with pluggable AgentRuntime interface — most agents supported.** + +- **URL**: https://github.com/jayminwest/overstory +- **npm**: `@os-eco/overstory-cli` +- **Language**: TypeScript (Bun runtime) +- **License**: MIT + +#### AgentRuntime interface (`src/runtimes/types.ts`) +Defines the contract each adapter must implement: +- Spawning +- Config deployment +- Guard enforcement +- Readiness detection +- Transcript parsing + +#### Supported runtimes (11) +Claude Code, Pi, Copilot, Cursor, Codex, Gemini CLI, Aider, Goose, Amp, OpenCode, Sapling. + +#### Architecture +- Agents run in isolated **git worktrees via tmux** +- Inter-agent messaging via **SQLite** (`.overstory/mail.db`, WAL mode) +- Tiered conflict resolution for merge +- Watchdog daemon for health monitoring +- Hierarchy: Orchestrator → Coordinator → Supervisor → Workers + +#### Integration with Electron +- TypeScript — good language fit +- `AgentRuntime` interface is the cleanest abstraction found +- **Con**: Requires Bun (not Node.js) +- **Con**: Hard dependency on tmux (not available on Windows, awkward in Electron) +- **Con**: Designed as CLI orchestrator, not embeddable library +- **Con**: Heavy — mail system, worktrees, watchdog are overhead we don't need + +#### What we can extract +The `AgentRuntime` interface pattern is the most instructive. We could model our own adapter interface after it, implementing only spawn/communicate/status methods. + +#### Reliability: 6/10 +#### Confidence: 5/10 — great architecture design but tmux/Bun deps make it impractical for Electron + +Source: [github.com/jayminwest/overstory](https://github.com/jayminwest/overstory) + +--- + +## Tier 2 — Useful Reference, Not Direct Import + +### 4. ComposioHQ Agent Orchestrator + +**Enterprise-grade TypeScript orchestrator with plugin architecture.** + +- **URL**: https://github.com/ComposioHQ/agent-orchestrator +- **Language**: TypeScript (91.5%), pnpm monorepo +- **npm**: Not published (build from source, `npm link -g packages/cli`) +- **License**: Not specified +- **Stars**: Growing, backed by Composio (well-funded company) + +#### Plugin architecture (8 slots) +| Slot | Default | Alternatives | +|------|---------|-------------| +| Runtime | tmux | docker, k8s, process | +| Agent | claude-code | codex, aider, opencode | +| Workspace | worktree | clone | +| Tracker | github | linear | +| Notifier | desktop | slack, composio, webhook | +| Terminal | iterm2 | web | + +All interfaces in `packages/core/src/types.ts`. Plugins implement one interface and export a `PluginModule`. + +#### Key stats +40,000 lines of TypeScript, 17 plugins, 3,288 tests. + +#### Integration with Electron +- TypeScript monorepo — compatible +- Plugin interface is clean and extensible +- **Con**: Not published as npm package +- **Con**: Heavy — includes dashboard, CI integration, PR management +- **Con**: tmux as default runtime +- **Con**: Designed for autonomous operation, not interactive control + +#### Reliability: 6/10 +#### Confidence: 5/10 — impressive codebase but too heavy for embedding + +Source: [github.com/ComposioHQ/agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) + +--- + +### 5. MCO (Multi-CLI Orchestrator) + +**Python-based neutral orchestration layer for CLI coding agents.** + +- **URL**: https://github.com/mco-org/mco +- **npm**: `@tt-a1i/mco` (Node.js wrapper around Python) +- **Language**: Python (core), Node.js (wrapper) +- **Requires**: Python 3.10+ + +#### Adapter architecture +Adding a new agent CLI requires implementing three hooks: +1. Auth check +2. Command builder +3. Output normalizer + +Supports two transport modes: Shim (stdout parsing) and ACP (JSON-RPC). + +#### Supported agents (5+) +Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code. Custom agents via `.mco/agents.yaml`. + +#### Features +- Parallel dispatch + consensus engine (`agreement_ratio`, `consensus_score`) +- JSON/SARIF/Markdown output +- Debate mode, divide mode (files/dimensions) +- MCP server mode for programmatic access + +#### Integration with Electron +- **Con**: Python dependency — very problematic for Electron distribution +- **Con**: Not a library, primarily CLI +- MCP server mode could work but adds complexity +- The 3-hook adapter pattern is a useful design reference + +#### Reliability: 5/10 +#### Confidence: 4/10 — Python dependency is a dealbreaker for Electron + +Source: [github.com/mco-org/mco](https://github.com/mco-org/mco) + +--- + +### 6. AWS CLI Agent Orchestrator (CAO) + +**AWS-backed orchestrator with supervisor-worker pattern via tmux + MCP.** + +- **URL**: https://github.com/awslabs/cli-agent-orchestrator +- **Language**: Python 3.10+ +- **Install**: `uv tool install` (not on PyPI) +- **License**: Apache 2.0 + +#### Supported providers (7) +Kiro CLI, Claude Code, Codex CLI, Gemini CLI, Kimi CLI, GitHub Copilot CLI, Q CLI. + +#### Orchestration patterns +1. **Handoff** — synchronous task transfer with wait-for-completion +2. **Assign** — asynchronous spawning for parallel execution +3. **Send Message** — direct communication with existing agents + +#### REST API +Server on `localhost:9889` — session management, terminal control, messaging. + +#### Integration with Electron +- **Con**: Python — not suitable for Electron +- **Con**: tmux dependency +- REST API approach could be adapted +- Agent profile system is well-designed (provider key in frontmatter) + +#### Reliability: 7/10 +#### Confidence: 4/10 — solid engineering (AWS) but Python/tmux deps block Electron use + +Source: [github.com/awslabs/cli-agent-orchestrator](https://github.com/awslabs/cli-agent-orchestrator), [AWS Blog](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) + +--- + +### 7. Network-AI + +**TypeScript multi-agent coordination with atomic shared state.** + +- **URL**: https://github.com/jovanSAPFIONEER/Network-AI +- **npm**: `network-ai` +- **Language**: TypeScript +- **License**: MIT + +#### Key concept +Solves the "last-write-wins" problem with atomic `propose -> validate -> commit` semantics using filesystem-based mutual exclusion. + +#### 17 adapters +LangChain, AutoGen, CrewAI, OpenAI Assistants, LlamaIndex, Semantic Kernel, Haystack, DSPy, Agno, MCP, Custom, OpenClaw, A2A, Codex, MiniMax, NemoClaw, APS. + +**Important caveat**: These are adapters for AI *frameworks* (LangChain, CrewAI), not CLI coding agents (Claude Code, Aider). The Codex adapter is for OpenAI API, not Codex CLI. + +#### Library usage +```typescript +import { LockedBlackboard, CustomAdapter, createSwarmOrchestrator } from 'network-ai'; +``` + +#### Integration with Electron +- TypeScript + npm — good language fit +- Importable as library +- **Con**: Solves a different problem (framework coordination, not CLI agent spawning) +- **Con**: No adapters for CLI coding agents specifically +- Blackboard pattern could be useful for inter-agent state + +#### Reliability: 5/10 +#### Confidence: 3/10 — wrong abstraction level for our needs + +Source: [github.com/jovanSAPFIONEER/Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) + +--- + +## Tier 3 — Ecosystem Context + +### 8. Pi (pi-mono) + +**TypeScript monorepo — coding agent toolkit with unified LLM API.** + +- **URL**: https://github.com/badlogic/pi-mono +- **npm**: `@mariozechner/pi-coding-agent` +- **Language**: TypeScript (Bun) +- **Stars**: 25,400+ + +Not a multi-agent orchestrator — it's a coding agent itself (like Claude Code but open source). Relevant because its modular package design (`pi-ai`, `pi-agent-core`, `pi-coding-agent`, `pi-tui`) shows how to abstract agent internals. Supports 15+ LLM providers. + +Source: [github.com/badlogic/pi-mono](https://github.com/badlogic/pi-mono) + +--- + +### 9. AI Code Agents SDK (Felix Arntz) + +**TypeScript SDK for vendor-lock-in-free coding agents.** + +- **Blog**: https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/ +- **Language**: TypeScript +- **Built on**: Vercel AI SDK +- **Status**: Very early stage (announced November 2025) + +Abstracts **Environment** (sandboxed execution contexts) and **Tools** (file system, commands) behind interfaces. Model-agnostic via Vercel AI SDK. + +**Not a CLI agent spawner** — it's an SDK for *building* coding agents, not orchestrating existing ones. No GitHub repository found (may be private or unreleased). + +#### Reliability: 2/10 (not yet available) +#### Confidence: 3/10 + +--- + +### 10. Claude Code Bridge (ccb) + +**Terminal-based multi-AI collaboration via split panes.** + +- **URL**: https://github.com/bfly123/claude_code_bridge +- **Stars**: 1,759 +- **Language**: **Python** (not TypeScript) + +Orchestrates Claude, Codex, Gemini, OpenCode, Droid through terminal multiplexer (WezTerm/tmux) split panes. 50-200 tokens per call via persistent sessions. + +**Not suitable**: Python, tmux-based, designed for human-visible terminal interaction. + +Source: [github.com/bfly123/claude_code_bridge](https://github.com/bfly123/claude_code_bridge) + +--- + +## Related Infrastructure + +### node-pty + xterm.js (Terminal Emulation in Electron) + +The foundational building blocks if we build our own solution: + +- **node-pty**: `npm install node-pty` — fork pseudoterminals in Node.js. Used by VS Code, Hyper, and many Electron terminal apps. Supports Linux, macOS, Windows (conpty). [github.com/microsoft/node-pty](https://github.com/microsoft/node-pty) +- **xterm.js**: Terminal emulator for the browser/Electron renderer. [github.com/xtermjs/xterm.js](https://github.com/xtermjs/xterm.js) +- **@loopmode/xpty**: React component + helpers for building terminals in Electron with xterm.js + node-pty. [github.com/loopmode/xpty](https://github.com/loopmode/xpty) + +This is essentially what Coder AgentAPI does in Go. We could replicate the approach in TypeScript using node-pty directly. + +**Important**: node-pty is **not thread-safe** and requires native compilation. Already used by many Electron apps successfully. + +--- + +### Anthropic Claude Agent SDK (Official) + +- **npm**: `@anthropic-ai/claude-agent-sdk` +- **URL**: https://github.com/anthropics/claude-agent-sdk-typescript +- **Docs**: https://platform.claude.com/docs/en/agent-sdk/typescript + +Official SDK for spawning Claude Code programmatically. Includes `spawnClaudeCodeProcess` option, `AgentDefinition` for subagents. Only works with Claude Code. + +--- + +### Awesome CLI Coding Agents (Curated List) + +Comprehensive directory of 80+ CLI coding agents + orchestrators: +- **URL**: https://github.com/bradAGI/awesome-cli-coding-agents + +Notable orchestrators from the list: +- **Superset** (7.4k stars) — terminal for coding agents, parallel sessions +- **Claude Squad** (6.4k stars) — tmux multi-session Claude Code +- **Crystal** (3.0k stars) — parallel agents in git worktrees +- **Toad** (2.7k stars) — agent orchestrator for parallel CLI sessions +- **Emdash** (2.7k stars) — concurrent coding agents + +--- + +## Key Findings + +### 1. No universal npm library exists +There is no `npm install universal-agent` that gives you a clean TypeScript interface to spawn and communicate with arbitrary CLI coding agents. The ecosystem is solving this problem in different ways (MCP servers, HTTP APIs, tmux wrappers, CLI tools) but none are designed as embeddable libraries for Electron. + +### 2. Two architectural approaches dominate + +**Terminal emulation** (AgentAPI approach): +- Spawn a PTY, type into it, parse output +- Works with ANY CLI agent without modification +- Fragile — depends on terminal output format +- Message boundaries are hard to detect + +**stdin/stdout protocol** (our current Claude Code approach): +- `--input-format stream-json --output-format stream-json` +- Clean structured communication +- Only works if CLI supports it +- Each agent has its own protocol (or none) + +### 3. Agent protocol fragmentation +Each CLI agent has a different communication protocol: +- **Claude Code**: stream-json stdin/stdout +- **Codex CLI**: `--json` flag, structured output +- **Gemini CLI**: No programmatic API documented +- **Goose**: Custom protocol +- **Aider**: Text-based, `--message` flag +- **OpenCode**: No public programmatic API + +This fragmentation is why projects like AgentAPI resort to terminal emulation — it's the only truly universal approach. + +### 4. MCP as potential unifier +MCP (Model Context Protocol) is emerging as a common integration point. All major coding agents now support MCP for tools, and projects like MCO and all-agents-mcp use MCP as the orchestration transport. However, MCP doesn't solve the agent *spawning* and *lifecycle management* problem. + +### 5. The ACP (Agent Client Protocol) is emerging +The Agent Client Protocol (mentioned in MCO's ACP mode and the Cursor ACP adapter) may become a standard for agent-to-agent communication, but it's too early and not widely adopted. + +--- + +## Proposed Architecture for Our Project + +Based on this research, the recommended approach is to build our own thin abstraction layer: + +```typescript +// AgentAdapter interface (inspired by Overstory's AgentRuntime + all-agents-mcp's IAgent) +interface AgentAdapter { + // Identity + readonly id: string; // "claude-code" | "codex" | "gemini" | etc. + readonly displayName: string; + + // Detection + isInstalled(): Promise; + getVersion(): Promise; + + // Lifecycle + spawn(config: AgentSpawnConfig): Promise; + + // Capabilities + supportsMcp(): boolean; + supportsStreamJson(): boolean; + supportsTeams(): boolean; +} + +interface AgentProcess { + // Communication + sendMessage(text: string): Promise; + onMessage(handler: (msg: AgentMessage) => void): void; + onStatus(handler: (status: AgentStatus) => void): void; + + // Lifecycle + isAlive(): boolean; + kill(): Promise; + + // Process + readonly pid: number; + readonly stdin: Writable; + readonly stdout: Readable; +} + +interface AgentSpawnConfig { + workingDir: string; + mcpConfig?: string; // path to MCP config file + model?: string; + maxTokens?: number; + disallowedTools?: string[]; + env?: Record; + systemPrompt?: string; +} +``` + +### Implementation approaches (ranked) + +**Option A: Direct child_process spawn with per-agent formatters (Recommended)** +- Use Node.js `child_process.spawn()` for each agent +- Each adapter knows the correct CLI flags and I/O format +- Similar to all-agents-mcp's `BaseAgent` approach +- Reliability: 8/10, Confidence: 9/10 + +**Option B: node-pty terminal emulation (AgentAPI approach in TS)** +- Use `node-pty` to spawn PTY for each agent +- Parse terminal output, inject keystrokes +- Works with any agent but fragile +- Reliability: 6/10, Confidence: 7/10 + +**Option C: Wrap Coder AgentAPI as subprocess** +- Spawn `agentapi server` as a sidecar process +- Communicate via HTTP API +- Leverage their 11 agent support +- Reliability: 7/10, Confidence: 6/10 (Go binary distribution complexity) + +**Option D: Fork all-agents-mcp's TypeScript code** +- Take the IAgent/BaseAgent pattern +- Extend with more agents +- Reliability: 6/10, Confidence: 7/10 + +--- + +## Sources + +- [Coder AgentAPI](https://github.com/coder/agentapi) — HTTP API for 11 coding agents (Go) +- [all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) — TypeScript MCP server for 4 agents +- [Overstory](https://github.com/jayminwest/overstory) — AgentRuntime interface with 11 runtimes (TS/Bun) +- [ComposioHQ Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) — TS monorepo, plugin architecture +- [MCO](https://github.com/mco-org/mco) — Python multi-CLI orchestrator with adapter hooks +- [AWS CLI Agent Orchestrator](https://github.com/awslabs/cli-agent-orchestrator) — Python, supervisor-worker pattern +- [Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) — TS, 17 framework adapters, npm library +- [Pi (pi-mono)](https://github.com/badlogic/pi-mono) — TS coding agent toolkit +- [Claude Code Bridge](https://github.com/bfly123/claude_code_bridge) — Python multi-AI collaboration +- [Awesome CLI Coding Agents](https://github.com/bradAGI/awesome-cli-coding-agents) — curated directory of 80+ agents +- [node-pty](https://github.com/microsoft/node-pty) — PTY for Node.js (Microsoft) +- [Anthropic Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript) — Official TS SDK +- [Felix Arntz blog — AI Code Agents SDK](https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/) — Vendor lock-in abstraction concept +- [AWS Blog — CLI Agent Orchestrator](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) diff --git a/docs/research/unified-llm-api-tools.md b/docs/research/unified-llm-api-tools.md new file mode 100644 index 00000000..3b8e0e8d --- /dev/null +++ b/docs/research/unified-llm-api-tools.md @@ -0,0 +1,571 @@ +# Unified LLM API Libraries for TypeScript/Electron + +> **Date:** 2026-03-24 +> **Goal:** Find the best library that provides a single API for calling multiple LLM providers (OpenAI, Anthropic, Google, etc.) from our Electron app. +> **Requirements:** TypeScript-native, tool calling, streaming, can run in Electron (no server), open source, actively maintained, MCP integration + +--- + +## TL;DR — Recommendation + +**Vercel AI SDK (`ai` + `@ai-sdk/*` providers)** is the clear winner for our use case. + +| Criteria | Winner | +|---|---| +| Best as a library (not framework) | Vercel AI SDK | +| Tool calling across providers | Vercel AI SDK | +| Streaming | Vercel AI SDK | +| TypeScript DX | Vercel AI SDK | +| MCP integration | Vercel AI SDK | +| Runs in Electron (no server) | Vercel AI SDK, multi-llm-ts | +| Community & maintenance | Vercel AI SDK | +| Lightweight / minimal footprint | multi-llm-ts | + +If we need something **even simpler** with zero framework overhead and 12 provider support, `multi-llm-ts` is a solid lightweight alternative (already used by a production Electron app — Witsy). + +--- + +## Candidates Compared + +### 1. Vercel AI SDK (RECOMMENDED) + +| | | +|---|---| +| **Package** | `ai` (core), `@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, etc. | +| **GitHub** | [github.com/vercel/ai](https://github.com/vercel/ai) | +| **Stars** | ~23K | +| **npm downloads** | ~4.5M/week (across `ai` + `@ai-sdk/*` packages) | +| **License** | Apache 2.0 | +| **Latest version** | ai@6.0.138 (March 2026) | +| **TypeScript** | Native TypeScript, written from scratch. Excellent DX. | +| **Contributors** | 597+ | + +**Provider coverage:** +100+ models supported. Official provider packages for: OpenAI, Anthropic, Google (Gemini), Mistral, Cohere, Amazon Bedrock, Azure OpenAI, xAI (Grok), Groq, Perplexity, Fireworks, Together AI, DeepSeek, Ollama (local), and 40+ community providers including OpenRouter, Portkey, etc. + +**Tool calling:** Full support via `generateText` and `streamText`. Multi-step tool execution loops with `stopWhen`. AI SDK 6 introduces `ToolLoopAgent` for automatic tool execution. `needsApproval: true` for human-in-the-loop. Type-safe tool definitions with Zod schemas. + +**Streaming:** First-class streaming via `streamText()` and `streamObject()`. Returns async iterable `textStream`. No custom parsing needed. + +**MCP integration:** Full MCP support since AI SDK 6. Built-in MCP client with `tools()` method that adapts MCP tools to AI SDK tools. Supports HTTP/SSE/stdio transports. OAuth authentication for MCP servers. Elicitation support (MCP servers can request user input). + +**Can run in Electron:** YES. `generateText()` and `streamText()` are pure Node.js functions — no web server required. Work directly in Electron's main process. Confirmed by Sentry's Electron + Vercel AI integration. Community project [electron-ai-chatbot](https://github.com/pashvc/electron-ai-chatbot) exists. + +**Maturity:** Very high. Used by Thomson Reuters, Clay, and "teams ranging from startups to Fortune 500 companies". 20M+ monthly downloads. Active development with frequent releases (multiple per week). + +**Strengths:** +- Most library-like: single function calls (`generateText`, `streamText`, `generateObject`), no framework lock-in +- Switch providers by changing one line of code +- Best TypeScript DX in the category +- Huge ecosystem of provider packages +- Excellent documentation at [ai-sdk.dev](https://ai-sdk.dev/) +- Built-in fallbacks in AI SDK 6 +- DevTools for debugging LLM calls + +**Weaknesses:** +- Provider packages add separate dependencies (though each is small) +- UI hooks (`useChat`, `useCompletion`) are React/web focused — not relevant for our Electron main process use +- Some newer features (AI SDK 6) are still stabilizing + +**Reliability: 9/10 | Confidence: 9/10** + +**Links:** +- [Official docs](https://ai-sdk.dev/docs/introduction) +- [Tool calling docs](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) +- [MCP tools docs](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) +- [Node.js getting started](https://ai-sdk.dev/docs/getting-started/nodejs) +- [AI SDK 6 announcement](https://vercel.com/blog/ai-sdk-6) +- [npm: ai](https://www.npmjs.com/package/ai) +- [GitHub](https://github.com/vercel/ai) + +--- + +### 2. multi-llm-ts (Lightweight Alternative) + +| | | +|---|---| +| **Package** | `multi-llm-ts` | +| **GitHub** | [github.com/nbonamy/multi-llm-ts](https://github.com/nbonamy/multi-llm-ts) | +| **Stars** | ~50 (small project) | +| **npm downloads** | ~211/week | +| **License** | MIT | +| **Latest version** | 4.6.2 (March 2026) | +| **TypeScript** | Native TypeScript | +| **Maintainers** | 1 | + +**Provider coverage:** +12 providers: OpenAI, Anthropic, Google, Mistral, Groq, Ollama, xAI, DeepSeek, Cerebras, Meta/Llama, Azure AI, OpenRouter. + +**Tool calling:** Built-in plugin/tool system. Define tools with parameter descriptions and execution logic. Tool calling handled automatically across all providers. + +**Streaming:** `complete()` (non-streaming) and `generate()` (streaming) methods. + +**MCP integration:** None built-in. + +**Can run in Electron:** YES. Already powering [Witsy](https://github.com/nbonamy/witsy) — a production Electron desktop AI assistant using 20+ providers through this library. This is the most proven Electron integration of any library on this list. + +**Maturity:** Active development, frequent releases. Small community but proven in production via Witsy. + +**Strengths:** +- Smallest, most focused library — does exactly one thing well +- Already proven in a real Electron desktop app +- MIT license +- Clean abstraction: `igniteEngine()` / `igniteModel()` → `complete()` / `generate()` +- AbortSignal support for cancellation +- Token usage tracking +- Multi-attachment support + +**Weaknesses:** +- Single maintainer — bus factor risk +- Very small community (~211 downloads/week) +- No MCP integration +- No structured output (generateObject equivalent) +- 12 providers vs 100+ in Vercel AI SDK +- Limited documentation + +**Reliability: 6/10 | Confidence: 7/10** + +**Links:** +- [npm: multi-llm-ts](https://www.npmjs.com/package/multi-llm-ts) +- [GitHub](https://github.com/nbonamy/multi-llm-ts) +- [Witsy (Electron app using it)](https://github.com/nbonamy/witsy) + +--- + +### 3. Mastra + +| | | +|---|---| +| **Package** | `@mastra/core` | +| **GitHub** | [github.com/mastra-ai/mastra](https://github.com/mastra-ai/mastra) | +| **Stars** | ~19.8K | +| **npm downloads** | ~300K/week | +| **License** | Apache 2.0 (core), Enterprise License (ee/ features) | +| **Latest version** | 1.x (January 2026 v1.0) | +| **TypeScript** | Native TypeScript, from Gatsby team | + +**Provider coverage:** +3,388 models from 94 providers — because it uses Vercel AI SDK under the hood for model routing. + +**Tool calling:** Full tool calling support. Define tools with schemas and descriptions. `ToolSearchProcessor` lets agents search for and load tools on demand. + +**Streaming:** Yes, via Vercel AI SDK. + +**MCP integration:** Yes, via `@mastra/mcp` package. Acts as both MCP client and server. Supports SSE, HTTP, and Hono-based MCP servers. MCP tool calls are traced with dedicated span types. + +**Can run in Electron:** Partially. Mastra has an official [Electron guide](https://mastra.ai/guides/getting-started/electron). However, it's designed as a server-side framework with HTTP endpoints. Using it in Electron's main process would mean importing a framework designed for servers into a desktop app. + +**Maturity:** High. v1.0 since January 2026. Y Combinator W25 batch ($13M funding). Used by Replit, PayPal, Sanity. + +**Strengths:** +- Huge provider coverage (94 providers through Vercel AI SDK) +- Built-in agents, workflows, memory, evals +- Clean TypeScript DX +- Strong MCP integration including MCP server authoring +- Backed by VC funding and large team +- Official Electron guide exists + +**Weaknesses:** +- It's a FRAMEWORK, not a library — brings entire agent/workflow/memory system +- Heavy dependency graph (`@mastra/core` pulls in many dependencies) +- Enterprise license for some features (RBAC, ACL) +- Designed primarily for server environments +- Overkill if you just need to call LLMs from Electron +- Uses Vercel AI SDK internally — so you'd be adding a framework layer on top of the library we actually need + +**Reliability: 8/10 | Confidence: 6/10** (for our "library" use case — it's a great framework but overkill) + +**Links:** +- [mastra.ai](https://mastra.ai/) +- [Docs](https://mastra.ai/docs) +- [Electron guide](https://mastra.ai/guides/getting-started/electron) +- [npm: @mastra/core](https://www.npmjs.com/package/@mastra/core) +- [GitHub](https://github.com/mastra-ai/mastra) +- [MCP integration docs](https://docs.mcp.run/integrating/tutorials/mcpx-mastra-ts/) + +--- + +### 4. LangChain.js + +| | | +|---|---| +| **Package** | `langchain`, `@langchain/core`, `@langchain/openai`, etc. | +| **GitHub** | [github.com/langchain-ai/langchainjs](https://github.com/langchain-ai/langchainjs) | +| **Stars** | ~17.3K | +| **npm downloads** | ~1M/week | +| **License** | MIT | +| **Latest version** | langchain@1.2.30 (March 2026) | +| **TypeScript** | TypeScript, ported from Python | + +**Provider coverage:** +100+ LLM providers, 50+ vector stores, hundreds of tools. + +**Tool calling:** Standardized `tool_calls` interface on AIMessage. `bind_tools()` and `create_tool_calling_agent()`. Dynamic tools and recovery from hallucinated tool calls (since v1.2.13). Custom Vitest matchers for tool call assertions. + +**Streaming:** Yes, via `streamEvents` and async iterators. Real-time streaming with `StreamEvents`. + +**MCP integration:** Community integrations exist but not first-party like Vercel AI SDK. + +**Can run in Electron:** Yes, technically (it's Node.js), but: +- Heavy: 101.2 kB gzipped bundle +- Designed for server environments +- Many abstractions add overhead + +**Maturity:** Very high. Largest ecosystem. LangSmith for observability. 8 maintainers. + +**Strengths:** +- Largest ecosystem and community +- Most integrations (100+ providers, 50+ vector stores) +- LangSmith for production observability +- LangGraph for complex agent workflows +- Mature, well-documented + +**Weaknesses:** +- Most framework-like — imposes architecture +- Heaviest bundle (101.2 kB gzipped) +- More boilerplate than Vercel AI SDK +- TypeScript feels like a port from Python (Python-first design) +- Frequent breaking changes historically +- "Powerful but sometimes overly complex for straightforward use cases" +- Edge runtime blocked + +**Reliability: 8/10 | Confidence: 5/10** (for our use case — great framework, wrong fit for lightweight Electron integration) + +**Links:** +- [langchain.com](https://www.langchain.com/) +- [JS docs](https://docs.langchain.com/oss/javascript/langchain/overview) +- [npm: langchain](https://www.npmjs.com/package/langchain) +- [GitHub](https://github.com/langchain-ai/langchainjs) +- [Tool calling with LangChain](https://blog.langchain.com/tool-calling-with-langchain/) + +--- + +### 5. Portkey AI Gateway + +| | | +|---|---| +| **Package** | `@portkey-ai/gateway` (self-hosted), `portkey-ai` (SDK), `@portkey-ai/vercel-provider` | +| **GitHub** | [github.com/Portkey-AI/gateway](https://github.com/Portkey-AI/gateway) | +| **Stars** | ~11K | +| **npm downloads** | Low (niche) | +| **License** | MIT | +| **Latest version** | gateway@1.15.2 | +| **TypeScript** | Written in TypeScript | + +**Provider coverage:** +1,600+ models. 200+ LLM providers. 50+ AI guardrails. + +**Tool calling:** Supported via OpenAI-compatible API. Also integrates as [Vercel AI SDK provider](https://ai-sdk.dev/providers/community-providers/portkey). + +**Streaming:** Yes. + +**MCP integration:** Has MCP Gateway feature for centralized MCP server management. + +**Can run in Electron:** PARTIALLY. The gateway itself can run via `npx @portkey-ai/gateway` (starts a local server). The SDK (`portkey-ai`) is a client that needs a running gateway. This means you'd need to either: (a) run the gateway as a subprocess in Electron, or (b) use the hosted Portkey service. Neither is ideal vs just importing a library. + +**Maturity:** High. 10B+ tokens processed daily. SOC2, HIPAA, GDPR compliant. Used by Postman, Haptik, Turing. + +**Strengths:** +- Enterprise-grade: fallbacks, retries, load balancing, guardrails +- 1,600+ models +- <1ms gateway latency, 122kb footprint +- Excellent observability and logging +- MCP Gateway for centralized tool management +- Integrates with Vercel AI SDK as a provider + +**Weaknesses:** +- Gateway architecture — needs a running server/proxy, doesn't work as a pure import +- For Electron, adds unnecessary complexity (subprocess management) +- Best as a production gateway, not as an embedded library +- Hosted service has latency (25-40ms added) +- Primarily designed for server/cloud deployments + +**Reliability: 9/10 | Confidence: 4/10** (excellent product, wrong architecture for embedded Electron use) + +**Links:** +- [portkey.ai](https://portkey.ai/) +- [Gateway docs](https://portkey.ai/docs/product/ai-gateway) +- [npm: @portkey-ai/gateway](https://www.npmjs.com/package/@portkey-ai/gateway) +- [GitHub](https://github.com/Portkey-AI/gateway) +- [Vercel AI SDK provider](https://ai-sdk.dev/providers/community-providers/portkey) + +--- + +### 6. OpenRouter SDK + +| | | +|---|---| +| **Package** | `@openrouter/sdk` | +| **GitHub** | [github.com/OpenRouterTeam/typescript-sdk](https://github.com/OpenRouterTeam/typescript-sdk) | +| **Stars** | ~148 | +| **npm downloads** | ~345K/week | +| **License** | Apache 2.0 | +| **Latest version** | 0.9.11 (beta) | +| **TypeScript** | Auto-generated from OpenAPI spec | + +**Provider coverage:** +300+ models from 60+ providers through OpenRouter's unified endpoint. + +**Tool calling:** Yes, built-in. Clean architecture for agentic workflows. + +**Streaming:** Yes. + +**MCP integration:** Not built-in. OpenRouter is a routing service, not an MCP-aware system. + +**Can run in Electron:** YES, but requires internet connectivity to OpenRouter's API. All requests go through OpenRouter's servers (adds 25-40ms latency). Cannot use API keys directly with providers — must go through OpenRouter. + +**Maturity:** SDK is in BETA. May have breaking changes between versions. + +**Strengths:** +- Simple: one API key, one endpoint, 300+ models +- Auto-generated types always match the API +- High weekly downloads (345K) +- Pay-as-you-go pricing +- Also available as Vercel AI SDK provider (`@openrouter/ai-sdk-provider`, 611 stars) + +**Weaknesses:** +- BETA status — not production-stable +- Requires routing through OpenRouter's servers (vendor dependency) +- Added latency per request +- Cannot use your own API keys directly with providers +- ESM-only (no CommonJS support) +- Not a library — it's a client for a service + +**Reliability: 6/10 | Confidence: 5/10** (good service, but vendor dependency + beta status) + +**Links:** +- [openrouter.ai](https://openrouter.ai/) +- [TypeScript SDK docs](https://openrouter.ai/docs/sdks/typescript) +- [npm: @openrouter/sdk](https://www.npmjs.com/package/@openrouter/sdk) +- [GitHub](https://github.com/OpenRouterTeam/typescript-sdk) +- [AI SDK provider](https://www.npmjs.com/package/@openrouter/ai-sdk-provider) + +--- + +### 7. Google Genkit + +| | | +|---|---| +| **Package** | `genkit` | +| **GitHub** | [github.com/firebase/genkit](https://github.com/firebase/genkit) | +| **Stars** | ~5.7K | +| **npm downloads** | ~moderate (41 dependents) | +| **License** | Apache 2.0 | +| **Latest version** | 1.30.1 | +| **TypeScript** | TypeScript + Go + Python | + +**Provider coverage:** +Google (Gemini), OpenAI, Anthropic, Ollama, AWS Bedrock, Azure OpenAI, Mistral, Cloudflare Workers AI, Hugging Face, and more via plugins. + +**Tool calling:** Full support via `defineTool` API. Interrupts for human-in-the-loop. Multi-agent architectures with sub-agents as tools. + +**Streaming:** Yes. + +**MCP integration:** Yes, supports connecting to external MCP servers for tool discovery and execution. + +**Can run in Electron:** Technically yes (Node.js), but designed for Firebase/Cloud Run deployment. Brings CLI, local dev UI, and server deployment patterns. + +**Maturity:** Built by Google, used in production by Firebase. Active development. + +**Strengths:** +- Built by Google, used in production +- Clean tool calling API +- Multi-agent support +- MCP integration +- Dev UI for debugging + +**Weaknesses:** +- Firebase/Google ecosystem bias +- Server-oriented design (CLI, cloud deployment focus) +- Smaller ecosystem than Vercel AI SDK or LangChain +- Not designed for desktop/Electron apps + +**Reliability: 7/10 | Confidence: 4/10** (good framework, Google-centric, not ideal for Electron) + +**Links:** +- [genkit.dev](https://genkit.dev/) +- [Firebase docs](https://firebase.google.com/docs/genkit) +- [npm: genkit](https://www.npmjs.com/package/genkit) +- [GitHub](https://github.com/firebase/genkit) +- [Tool calling docs](https://genkit.dev/docs/js/tool-calling/) + +--- + +### 8. Bifrost (Maxim AI) + +| | | +|---|---| +| **Package** | `@maximhq/bifrost` (via npx) | +| **GitHub** | [github.com/maximhq/bifrost](https://github.com/maximhq/bifrost) | +| **Stars** | ~2K+ | +| **License** | Source-available (check repo) | +| **Language** | Go (not TypeScript) | + +**Provider coverage:** +15+ providers through OpenAI-compatible API. + +**Tool calling:** Yes, via "Code Mode" — innovative approach reducing token usage by 50%. + +**MCP integration:** Yes, acts as both MCP client and server. Centralized MCP tool management. + +**Can run in Electron:** NO — it's a Go binary that runs as a server. Would need to be spawned as a subprocess and communicated with via HTTP. + +**Strengths:** +- Blazing fast: 11us overhead (50x faster than LiteLLM) +- Code Mode innovation for tool calling +- Strong MCP gateway features + +**Weaknesses:** +- Go binary, not a JS library +- Requires running a separate server process +- Wrong architecture for embedded Electron use + +**Reliability: 7/10 | Confidence: 2/10** (great gateway, completely wrong for our use case) + +**Links:** +- [docs.getbifrost.ai](https://docs.getbifrost.ai/overview) +- [GitHub](https://github.com/maximhq/bifrost) + +--- + +## Comparison Matrix + +| Library | Stars | npm/week | Tool Calling | Streaming | MCP | Electron | TypeScript | License | Library vs Framework | +|---|---|---|---|---|---|---|---|---|---| +| **Vercel AI SDK** | 23K | 4.5M | Excellent | Excellent | Full (v6) | YES | Native | Apache 2.0 | Library | +| **multi-llm-ts** | ~50 | 211 | Good | Good | No | YES (proven) | Native | MIT | Library | +| **Mastra** | 19.8K | 300K | Excellent | Excellent | Full | Partial | Native | Apache 2.0* | Framework | +| **LangChain.js** | 17.3K | 1M | Excellent | Good | Partial | Heavy | Ported | MIT | Framework | +| **Portkey** | 11K | Low | Good | Yes | MCP Gateway | Needs server | Native TS | MIT | Gateway | +| **OpenRouter SDK** | 148 | 345K | Good | Yes | No | Via service | Auto-gen | Apache 2.0 | Service client | +| **Google Genkit** | 5.7K | Moderate | Good | Yes | Yes | Server-focused | Native | Apache 2.0 | Framework | +| **Bifrost** | 2K+ | N/A | Innovative | Yes | Full | No (Go binary) | N/A | Source-avail | Gateway | + +--- + +## Architecture for Our Electron App + +### Recommended Approach: Vercel AI SDK in Electron Main Process + +``` +Renderer (React UI) + │ + │ IPC (ipcMain / ipcRenderer) + │ +Main Process (Node.js) + ├── AI SDK Core (generateText, streamText, generateObject) + │ ├── @ai-sdk/openai → OpenAI API + │ ├── @ai-sdk/anthropic → Anthropic API + │ ├── @ai-sdk/google → Google Gemini API + │ └── @ai-sdk/xai → xAI/Grok API + │ + ├── MCP Client (AI SDK built-in) + │ └── Connect to MCP servers for tool discovery + │ + └── API Key Storage (local, secure) +``` + +### Installation + +```bash +pnpm add ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google +``` + +### Example Usage (Electron Main Process) + +```typescript +import { generateText, streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { google } from '@ai-sdk/google'; + +// Switch provider by changing one line +const model = anthropic('claude-sonnet-4-20250514'); +// const model = openai('gpt-4o'); +// const model = google('gemini-2.0-flash'); + +// Non-streaming +const { text } = await generateText({ + model, + prompt: 'Explain quantum computing', +}); + +// Streaming +const result = streamText({ + model, + prompt: 'Write a story', +}); +for await (const chunk of result.textStream) { + // Send to renderer via IPC + mainWindow.webContents.send('ai:chunk', chunk); +} + +// Tool calling +const { text, toolCalls } = await generateText({ + model, + tools: { + getWeather: { + description: 'Get weather for a location', + parameters: z.object({ city: z.string() }), + execute: async ({ city }) => fetchWeather(city), + }, + }, + prompt: 'What is the weather in Tokyo?', +}); +``` + +--- + +## Decision + +**Primary choice: Vercel AI SDK (`ai` + provider packages)** +- Reliability: 9/10 +- Confidence: 9/10 +- Reason: Best TypeScript DX, most library-like, full MCP support, huge ecosystem, works in Electron main process, active development + +**Fallback / lightweight alternative: `multi-llm-ts`** +- Reliability: 6/10 +- Confidence: 7/10 +- Reason: Already proven in production Electron app (Witsy), minimal footprint, but small community and no MCP + +**NOT recommended for our use case:** +- LangChain.js — too heavy, framework-oriented, Python-first design +- Mastra — excellent framework but overkill (and uses Vercel AI SDK internally anyway) +- Portkey/Bifrost — gateway architecture, needs running server +- OpenRouter SDK — vendor dependency, beta status +- Google Genkit — server/Firebase oriented + +--- + +## Sources + +- [Vercel AI SDK — Official docs](https://ai-sdk.dev/docs/introduction) +- [Vercel AI SDK — GitHub](https://github.com/vercel/ai) +- [AI SDK 6 announcement](https://vercel.com/blog/ai-sdk-6) +- [AI SDK MCP tools](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) +- [AI SDK Tool Calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) +- [LangChain.js — GitHub](https://github.com/langchain-ai/langchainjs) +- [LangChain.js — npm](https://www.npmjs.com/package/langchain) +- [LangChain vs Vercel AI SDK vs OpenAI SDK: 2026 Guide](https://strapi.io/blog/langchain-vs-vercel-ai-sdk-vs-openai-sdk-comparison-guide) +- [Mastra — Official site](https://mastra.ai/) +- [Mastra — GitHub](https://github.com/mastra-ai/mastra) +- [Mastra Electron guide](https://mastra.ai/guides/getting-started/electron) +- [Mastra Licensing](https://mastra.ai/docs/community/licensing) +- [Portkey AI Gateway — GitHub](https://github.com/Portkey-AI/gateway) +- [Portkey AI docs](https://portkey.ai/docs/product/ai-gateway) +- [Portkey Vercel provider](https://ai-sdk.dev/providers/community-providers/portkey) +- [OpenRouter — TypeScript SDK docs](https://openrouter.ai/docs/sdks/typescript) +- [OpenRouter — npm](https://www.npmjs.com/package/@openrouter/sdk) +- [Google Genkit — GitHub](https://github.com/firebase/genkit) +- [Genkit Tool Calling](https://genkit.dev/docs/js/tool-calling/) +- [Bifrost — GitHub](https://github.com/maximhq/bifrost) +- [Bifrost docs](https://docs.getbifrost.ai/overview) +- [multi-llm-ts — GitHub](https://github.com/nbonamy/multi-llm-ts) +- [multi-llm-ts — npm](https://www.npmjs.com/package/multi-llm-ts) +- [Witsy (Electron app using multi-llm-ts)](https://github.com/nbonamy/witsy) +- [3 Best Open Source LiteLLM Alternatives in 2026](https://openalternative.co/alternatives/litellm) +- [Best LiteLLM Alternatives in 2026](https://www.getmaxim.ai/articles/best-litellm-alternatives-in-2026/) +- [AI Framework Comparison: Vercel AI SDK, Mastra, Langchain and Genkit](https://komelin.com/blog/ai-framework-comparison) +- [Top 5 TypeScript AI Agent Frameworks 2026](https://blog.agentailor.com/posts/top-typescript-ai-agent-frameworks-2026) +- [Sentry Electron + Vercel AI integration](https://docs.sentry.io/platforms/javascript/guides/electron/configuration/integrations/vercelai/) +- [Electron AI Chatbot](https://github.com/pashvc/electron-ai-chatbot) diff --git a/docs/research/unified-mcp-architecture.md b/docs/research/unified-mcp-architecture.md new file mode 100644 index 00000000..b2c92a00 --- /dev/null +++ b/docs/research/unified-mcp-architecture.md @@ -0,0 +1,485 @@ +# Unified MCP Architecture: Should Claude Also Use MCP for Kanban? + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: deep analysis of `mcp-server/`, `agent-teams-controller/`, `TeamProvisioningService.ts`, `TeamMcpConfigBuilder.ts`, and data flow through file watchers + +--- + +## The Question + +If Codex/Gemini use MCP for kanban management, should Claude also use MCP instead of its native built-in tools? This would unify the architecture into a single code path. + +--- + +## Current State: What Exists Today + +### MCP Server (30+ tools, fully provider-agnostic) + +Our `mcp-server/` package exposes these tools via FastMCP over stdio: + +| Category | Tools | Count | +|----------|-------|-------| +| Tasks | `task_create`, `task_create_from_message`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` | 17 | +| Kanban | `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` | 6 | +| Review | `review_request`, `review_start`, `review_approve`, `review_request_changes` | 4 | +| Messages | `message_send` | 1 | +| Processes | `process_register`, `process_list`, `process_unregister`, `process_stop` | 4 | +| Cross-team | `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` | 3 | +| Runtime | `team_launch`, `team_stop` | 2 | +| **Total** | | **37** | + +### Claude's Native Built-in Tools (Claude Code Agent Teams) + +These exist ONLY inside Claude Code CLI and cannot be replaced: + +| Tool | Purpose | Can MCP replace? | +|------|---------|------------------| +| `TeamCreate` | Creates team config on disk, initializes team state | Partially (MCP can write config, but Claude Code uses this to enter "team mode") | +| `TeamDelete` | Deletes team, cleans up processes | Partially | +| `TaskCreate` (Agent tool with `team_name`) | Spawns a teammate subprocess | **NO** -- this is process spawning, not task creation | +| `SendMessage` | Claude's native inbox message delivery | Partially (MCP `message_send` writes to same files) | +| `TaskGet` | Claude's native task query | Yes, `task_get` MCP does the same | +| `TaskList` | Claude's native task listing | Yes, `task_list` MCP does the same | +| `TaskUpdate` | Claude's native task update | Yes, `task_set_status`/`task_set_owner` MCP do the same | + +**Critical insight**: Claude Code's `TaskCreate` with `team_name` parameter is NOT a task-creation tool -- it's a **teammate process spawner**. It tells Claude Code CLI to fork a new subprocess for a teammate. No MCP tool can replace this because it's an internal CLI operation. + +### Data Flow: Where Files Live + +Both Claude's native tools AND our MCP server write to the **same directories**: + +``` +~/.claude/ + teams// + config.json -- team configuration + kanban-state.json -- kanban board state + processes.json -- registered processes + members.meta.json -- member metadata + inboxes/ + .json -- per-member inbox messages + user.json -- messages to the user + task-attachments/ + / -- file attachments + tasks// + .json -- individual task files +``` + +**This is already a shared data layer.** Our MCP server uses `agent-teams-controller` which reads/writes these exact files. Claude Code CLI also reads/writes these files via its built-in Agent Teams feature. The file watchers in `src/main/` detect changes from ANY source. + +### How Claude ALREADY Uses MCP + +Claude Code agents (both lead and teammates) **already** receive our MCP server via `--mcp-config`: + +``` +TeamMcpConfigBuilder.writeConfigFile() + → generates temp JSON config pointing to mcp-server/dist/index.js + → passed to Claude CLI via --mcp-config + → Claude Code loads our MCP tools alongside its built-in tools +``` + +The prompt in `buildTeamCtlOpsInstructions()` teaches Claude to use MCP tools: +``` +Internal task board tooling (MCP): +- Use the board-management MCP tools for tasks that must appear on the team board +``` + +And `buildMemberSpawnPrompt()` instructs teammates: +``` +First call member_briefing to learn your current assigned tasks... +Use task_start/task_complete/task_add_comment to track progress... +``` + +**Claude Code agents already use our MCP tools for task/kanban management.** They use native tools only for: team creation, teammate spawning, and direct messaging (though `message_send` MCP also works). + +--- + +## Three Architectures Compared + +### Architecture A: Dual-Path (Current Proposal for Multi-Provider) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + | config, kanban, | | .json files | + | inboxes, processes | | | + +----+----------+----+ +-----+------------+-----+ + | | | | + | | | | + +----+----+ +---+--------+ +---+----+ +-----+------+ + | Claude | | MCP Server | | Claude | | MCP Server | + | Native | | (agent- | | Native | | (agent- | + | Tools | | teams-mcp) | | Tools | | teams-mcp) | + +---------+ +-----+------+ +--------+ +-----+------+ + | | | | + +----+----+ +----+-----+ +----+----+ +----+-----+ + | Claude | | Codex/ | | Claude | | Codex/ | + | Code | | Gemini/ | | Code | | Gemini/ | + | CLI | | Any MCP | | CLI | | Any MCP | + +---------+ | Agent | +---------+ | Agent | + +----------+ +----------+ +``` + +**Data flow:** +- Claude -> native built-in tools -> writes directly to `~/.claude/teams/` and `~/.claude/tasks/` +- Claude -> MCP tools -> `agent-teams-controller` -> writes to same files +- Codex/Gemini -> MCP tools -> `agent-teams-controller` -> writes to same files +- File watchers detect ALL changes -> UI updates + +| Criterion | Score | +|-----------|-------| +| Reliability | **9/10** | +| Confidence | **9/10** | +| Effort | 3-4 weeks | +| Risk | Very Low | +| Code reuse | 100% | + +**Pros:** +- Zero risk to existing Claude Code functionality +- Claude uses its battle-tested native tools (TeamCreate, Agent/Task tool for spawning) +- MCP tools handle task/kanban CRUD (Claude already uses these) +- External agents use MCP exclusively +- Both paths write to same files, file watchers don't care who writes +- 30+ MCP tools already exist and are tested + +**Cons:** +- Two "entry points" for writes (native tools + MCP tools), though they share the same data layer +- Claude has redundant tools (native TaskGet + MCP task_get), but the prompt steers which to use +- If agent-teams-controller changes, both native and MCP paths need verification + +--- + +### Architecture B: Unified MCP (ALL agents use MCP only) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + +--------+-----------+ +--------+---------------+ + | | + +----------+---------------+ + | + +--------+--------+ + | MCP Server | + | (agent-teams- | + | controller) | + +--------+--------+ + | + +-------------+-------------+ + | | | + +----+----+ +----+-----+ +-----+----+ + | Claude | | Codex/ | | Gemini/ | + | Code | | Gemini | | Other | + | CLI | | CLI | | Agents | + +---------+ +----------+ +----------+ +``` + +**Data flow:** +- ALL agents -> MCP tools only -> `agent-teams-controller` -> writes to `~/.claude/tasks/` and `~/.claude/teams/` +- Claude Code's native tools (TeamCreate, TaskCreate, SendMessage) are DISABLED or unused +- File watchers detect changes -> UI updates + +| Criterion | Score | +|-----------|-------| +| Reliability | **4/10** | +| Confidence | **3/10** | +| Effort | 8-12 weeks | +| Risk | Very High | +| Code reuse | ~40% | + +**Pros:** +- Single code path for all agents +- Single set of tools to maintain +- Architecturally "clean" + +**Cons -- and this is where the analysis gets critical:** + +1. **Cannot disable Claude's `TaskCreate` (Agent tool with team_name)** + - This is how Claude Code spawns teammate subprocesses + - There is no MCP equivalent -- MCP tools return JSON responses, they cannot fork processes + - `--disallowedTools TaskCreate` would break teammate spawning entirely + - Our `team_launch` MCP tool talks to the desktop runtime HTTP API -- it's a different mechanism (launches the whole team, not individual teammates) + +2. **Cannot fully replace `TeamCreate`** + - `TeamCreate` puts Claude Code CLI into "team mode" -- it enables Agent Teams features, stdin relay, inbox monitoring + - Writing `config.json` via MCP creates the files but doesn't activate the CLI-side features + - The CLI needs to be told about the team through its own internal protocol + +3. **Cannot fully replace `SendMessage`** + - Our MCP `message_send` writes to inbox files, which works for teammates (they read inbox files directly) + - But the lead reads messages via stdin relay (`relayLeadInboxMessages()`). MCP `message_send` to lead would require the relay to detect the file write and relay it -- this works but is a longer path with more latency + - Risk of message delivery race conditions during high-frequency messaging + +4. **Prompt rewrite is massive and risky** + - `buildProvisioningPrompt()` (95 LOC) teaches Claude to use `TeamCreate` + `Agent` tool -- would need complete rewrite + - `buildPersistentLeadContext()` (100+ LOC) references built-in tools throughout + - `buildMemberSpawnPrompt()` references `member_briefing` MCP tool (this part is already MCP-based) + - Total: ~300 LOC of prompt engineering that took months to tune for delegation-first behavior, task board discipline, review workflow + - Any prompt change risks breaking the finely-tuned agent behavior + +5. **Token overhead from MCP tool descriptions** + - 37 MCP tools * ~50-100 tokens each = 1,850-3,700 additional tokens per turn + - Claude's native tools don't consume context (they're built into the CLI) + - For long sessions this accumulates significantly + +6. **MCP tool discovery overhead** + - Each MCP tool call has stdio round-trip overhead vs native tool calls which are in-process + - For high-frequency operations (agent spawning many tasks) this adds latency + +7. **Loss of Claude Code optimizations** + - Claude Code's built-in tools are optimized for its internal state machine + - `TeamCreate` triggers internal event routing, session persistence, teammate monitoring + - Replacing with MCP tools means these side effects would need to be triggered differently + +--- + +### Architecture C: Hybrid Unified (RECOMMENDED) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + | config, kanban, | | .json files | + | inboxes, processes | | | + +--+---------+---+---+ +---+---------+---+------+ + | | | | | | + | +----+---+----+ | +----+---+-----+ + | | agent-teams- | | | agent-teams- | + | | controller +-------+ | controller | + | | (shared | | (shared | + | | data layer) | | data layer) | + | +----+---------+ +-----+---------+ + | | | + | +----+---------+ +-----+---------+ + | | MCP Server | | MCP Server | + | | (agent- | | (agent- | + | | teams-mcp) | | teams-mcp) | + | +----+---------+ +-----+---------+ + | | | + +----+----+ | +----------+ +-----+----+ + | Claude | +----+ Codex/ | | Gemini/ | + | Native | | Any MCP | | Other | + | Tools: | | Agent | | Agents | + | Team | +----------+ +----------+ + | Create, | + | Agent | Claude ALSO uses MCP for: + | Spawn, | task_create, task_get, task_list, + | Send | task_set_status, kanban_get, + | Message | kanban_set_column, review_request, + +---------+ review_approve, message_send, etc. +``` + +**Data flow:** +- Claude -> native tools for LIFECYCLE operations (TeamCreate, Agent/Task spawning, SendMessage to lead) +- Claude -> MCP tools for CRUD operations (task management, kanban, review, comments) -- **already happens today** +- Codex/Gemini -> MCP tools for ALL operations +- ALL writes go to the same `~/.claude/` directories via `agent-teams-controller` +- File watchers detect ALL changes regardless of source + +| Criterion | Score | +|-----------|-------| +| Reliability | **9/10** | +| Confidence | **9/10** | +| Effort | 3-4 weeks (same as Architecture A) | +| Risk | Very Low | +| Code reuse | 100% | + +**Pros:** +- Claude keeps its native tools for things MCP cannot do (process spawning, entering team mode) +- Claude uses MCP for task/kanban CRUD -- THIS IS ALREADY THE CASE TODAY +- External agents use MCP exclusively -- works today with our 37 tools +- Single data layer (`agent-teams-controller`) for all writes +- File watchers are source-agnostic +- Zero prompt rewriting for Claude +- Zero risk to existing functionality + +**Cons:** +- Claude has both native + MCP tools available (mild complexity) +- Need to ensure no conflicts when Claude's native tools and MCP tools modify the same task + +--- + +## Critical Finding: Architecture C IS Architecture A + +After thorough analysis, Architectures A and C are **functionally identical** because: + +1. **Claude already uses our MCP tools for kanban/task management** -- the prompt explicitly instructs this via `buildTeamCtlOpsInstructions()` +2. **Claude only uses native tools for what MCP cannot do** -- TeamCreate (entering team mode), Agent tool (spawning subprocesses), SendMessage (lead stdin relay) +3. **Both paths already write to the same files** -- `agent-teams-controller` is the shared data layer + +The "dual-path" concern is a misconception. There aren't two competing paths -- there's one path for **lifecycle operations** (Claude Code native) and one path for **data operations** (MCP), and they already coexist. + +--- + +## Does Our MCP Server Write to the Same Files as Claude's Native Tools? + +**YES, unequivocally.** + +Evidence from source code: + +1. `agent-teams-controller/src/internal/runtimeHelpers.js` line 117-124: +```javascript +function getPaths(flags, teamName) { + const claudeDir = getClaudeDir(flags); // defaults to ~/.claude + const teamDir = path.join(claudeDir, 'teams', safeTeam); + const tasksDir = path.join(claudeDir, 'tasks', safeTeam); + const kanbanPath = path.join(teamDir, 'kanban-state.json'); + const processesPath = path.join(teamDir, 'processes.json'); + return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; +} +``` + +2. `mcp-server/src/controller.ts` uses `createController({ teamName })` which calls `getPaths()` above +3. Claude Code's native Agent Teams also writes to `~/.claude/teams//` and `~/.claude/tasks//` +4. Both use the same JSON file format with atomic write (temp file + rename) + +**Conflict risk**: Very low. File writes use atomic rename (`writeJson` creates a temp file then `fs.renameSync`). The `fileLock.js` module provides advisory locking for concurrent writes. Task files are per-task (one JSON per task), so different agents working on different tasks don't collide. + +--- + +## Architecture Decision + +### Architecture B (Unified MCP-only) is NOT viable + +The fundamental blocker: **Claude Code's Agent Teams is a CLI feature, not a data feature.** The built-in tools (TeamCreate, Agent tool for spawning) trigger internal CLI state changes that cannot be replicated via MCP. Disabling them would break: + +- Team mode activation +- Teammate process spawning +- Lead inbox relay +- Tool approval flow +- Post-compact context recovery +- Auth retry logic + +These are 7,982 LOC of battle-tested code in `TeamProvisioningService.ts` that would need to be rebuilt from scratch with worse ergonomics. + +### Architecture A/C (Hybrid) is already the architecture we have + +The "should Claude use MCP?" question has already been answered: **Claude already uses MCP for kanban/task operations.** The prompt instructs it. The `--mcp-config` flag delivers our MCP server to every Claude Code agent (lead and teammates). + +The only remaining question is: what do we need to add to support Codex/Gemini? + +--- + +## What's Actually Needed for Multi-Provider Support + +### Already complete (0 additional work) + +- MCP server with 37 tools +- `agent-teams-controller` as provider-agnostic data layer +- File watchers that detect changes from any source +- Atomic file writes to prevent corruption +- HTTP control API for launch/stop + +### Needed: New MCP tools for external agent lifecycle + +``` +team_join -- register as external team member (provider, model metadata) +team_leave -- unregister from team +team_list_teams -- discover available teams +team_get_config -- get team configuration and member list +member_heartbeat -- keepalive signal for external agents +task_poll_assigned -- poll for tasks assigned to this agent +task_claim -- claim an unassigned pending task +``` + +**Files to add:** +- `mcp-server/src/tools/memberTools.ts` (new) +- `mcp-server/src/tools/teamDiscoveryTools.ts` (new) +- `agent-teams-controller/src/internal/memberLifecycle.js` (new) + +**Files to modify:** +- `mcp-server/src/tools/index.ts` -- register new tool modules +- `agent-teams-controller/src/internal/runtimeHelpers.js` -- member metadata helpers +- `src/shared/types/team.ts` -- add `provider?: string`, `model?: string` fields + +**Files unchanged (0 modifications):** +- `TeamProvisioningService.ts` -- untouched +- `TeamDataService.ts` -- reads data generically, will pick up new fields +- `TeamMcpConfigBuilder.ts` -- untouched (Claude-specific) +- All prompt engineering -- untouched + +### Needed: UI enhancements + +- Provider badge/icon on member cards +- "External agent" indicator on kanban task cards +- Different color/treatment for externally-managed agents + +--- + +## Comparison Matrix + +| Criterion | A: Dual-Path | B: Unified MCP | C: Hybrid (=A) | +|-----------|:---:|:---:|:---:| +| Reliability | 9/10 | 4/10 | **9/10** | +| Confidence | 9/10 | 3/10 | **9/10** | +| Effort (weeks) | 3-4 | 8-12 | **3-4** | +| Risk level | Very Low | Very High | **Very Low** | +| Existing code reuse | 100% | ~40% | **100%** | +| Breaks Claude flow? | No | Yes | **No** | +| Breaks prompts? | No | Yes (300+ LOC rewrite) | **No** | +| Single data layer? | Yes | Yes | **Yes** | +| Claude keeps optimizations? | Yes | No | **Yes** | +| Supports Codex/Gemini? | Yes, via MCP | Yes, via MCP | **Yes, via MCP** | +| Token overhead | None extra | +1.8-3.7K tokens/turn | **None extra** | +| MCP standard compliance | Yes | Yes | **Yes** | +| Incremental delivery? | Yes | No | **Yes** | +| Time to first external agent | 2-3 weeks | 8+ weeks | **2-3 weeks** | + +--- + +## Risks and Mitigations + +| Risk | Probability | Impact | Mitigation | +|------|:-----------:|:------:|------------| +| MCP tool conflicts with native tools | Low | Medium | Tools operate on different task IDs; atomic writes; file-level locking in `fileLock.js` | +| External agent writes corrupt state | Low | High | `agent-teams-controller` validates all inputs; atomic write-rename pattern; per-task file isolation | +| External agent doesn't follow workflow | Medium | Low | `member_briefing` provides onboarding; tool descriptions guide behavior; `task_set_clarification` for issues | +| Performance under many agents | Low | Medium | File I/O is the bottleneck (same as now); no additional overhead | +| Claude Code updates break file format | Low | High | `agent-teams-controller` is our adapter layer -- update it when format changes | +| MCP protocol evolution | Very Low | Low | FastMCP library handles protocol; MCP spec is stable (v1.0+) | + +--- + +## Conclusion + +**Architecture C (Hybrid) is the answer, and it's essentially what we already have.** + +The realization that resolves the question: Claude Code already uses our MCP tools for task/kanban management. The "should Claude use MCP too?" question is already answered with "yes, and it does." Claude keeps its native tools for the things that ONLY Claude Code can do (process spawning, team mode activation), and uses MCP for everything that's shared (tasks, kanban, review, messages, comments). + +For Codex/Gemini, we add ~7 new MCP tools for agent lifecycle management. That's it. No architectural changes, no prompt rewrites, no refactoring. The data layer is already shared, the file watchers are already source-agnostic, and the MCP server already exposes the full API. + +The **single most important insight** from this analysis: the architecture is NOT "dual-path." It's a single shared data layer (`agent-teams-controller`) with two access methods -- native tools for Claude Code internal operations, MCP tools for everything else. Both access methods are complementary, not competing. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f9121fca..2eb209d8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -76,7 +76,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts'), - 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts') + 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'), + 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 2e66083b..994ba2d3 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -108,4 +108,31 @@ declare module 'agent-teams-controller' { } export const protocols: ProtocolsApi; + + export type AgentTeamsMcpToolGroupId = + | 'task' + | 'kanban' + | 'review' + | 'message' + | 'process' + | 'runtime' + | 'crossTeam'; + + export interface AgentTeamsMcpToolGroup { + id: AgentTeamsMcpToolGroupId; + teammateOperational: boolean; + toolNames: readonly string[]; + } + + export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; + export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index ec5b94eb..e765bdb2 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -1,5 +1,10 @@ import type { FastMCP } from 'fastmcp'; +import agentTeamsControllerModule from 'agent-teams-controller'; + +const { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } = + agentTeamsControllerModule; + import { registerCrossTeamTools } from './crossTeamTools'; import { registerKanbanTools } from './kanbanTools'; import { registerMessageTools } from './messageTools'; @@ -8,12 +13,25 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; +const REGISTRATION_BY_GROUP = { + task: registerTaskTools, + kanban: registerKanbanTools, + review: registerReviewTools, + message: registerMessageTools, + process: registerProcessTools, + runtime: registerRuntimeTools, + crossTeam: registerCrossTeamTools, +} as const; + +export const AGENT_TEAMS_MCP_REGISTRATION_GROUPS = AGENT_TEAMS_MCP_TOOL_GROUPS.map((group) => ({ + ...group, + register: REGISTRATION_BY_GROUP[group.id as keyof typeof REGISTRATION_BY_GROUP], +})); + +export { AGENT_TEAMS_REGISTERED_TOOL_NAMES }; + export function registerTools(server: FastMCP) { - registerTaskTools(server); - registerKanbanTools(server); - registerReviewTools(server); - registerMessageTools(server); - registerProcessTools(server); - registerRuntimeTools(server); - registerCrossTeamTools(server); + for (const group of AGENT_TEAMS_MCP_REGISTRATION_GROUPS) { + group.register(server); + } } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index ac3f41e5..d014c7c3 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -3,7 +3,7 @@ import http from 'http'; import os from 'os'; import path from 'path'; -import { registerTools } from '../src/tools'; +import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools'; type RegisteredTool = { name: string; @@ -30,45 +30,6 @@ function parseJsonToolResult(result: unknown) { describe('agent-teams-mcp tools', () => { const tools = collectTools(); - const expectedToolNames = [ - 'cross_team_get_outbox', - 'cross_team_list_targets', - 'cross_team_send', - 'kanban_add_reviewer', - 'kanban_clear', - 'kanban_get', - 'kanban_list_reviewers', - 'kanban_remove_reviewer', - 'kanban_set_column', - 'member_briefing', - 'message_send', - 'process_list', - 'process_register', - 'process_stop', - 'process_unregister', - 'review_approve', - 'review_request', - 'review_request_changes', - 'review_start', - 'task_add_comment', - 'task_attach_comment_file', - 'task_attach_file', - 'task_briefing', - 'task_complete', - 'task_create', - 'task_create_from_message', - 'task_get', - 'task_get_comment', - 'task_link', - 'task_list', - 'task_set_clarification', - 'task_set_owner', - 'task_set_status', - 'task_start', - 'task_unlink', - 'team_launch', - 'team_stop', - ] as const; function getTool(name: string) { const tool = tools.get(name); @@ -147,7 +108,7 @@ describe('agent-teams-mcp tools', () => { } it('registers the full expected MCP tool surface', () => { - expect([...tools.keys()].sort()).toEqual([...expectedToolNames]); + expect([...tools.keys()].sort()).toEqual([...AGENT_TEAMS_REGISTERED_TOOL_NAMES].sort()); }); it('accepts explicit conversation threading fields for cross_team_send', () => { diff --git a/package.json b/package.json index 1a0e0b3a..4e9f89b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "1.0.0", + "version": "1.1.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { @@ -121,6 +121,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "agent-teams-controller": "workspace:*", + "@claude-teams/agent-graph": "workspace:*", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json new file mode 100644 index 00000000..0144aa33 --- /dev/null +++ b/packages/agent-graph/package.json @@ -0,0 +1,25 @@ +{ + "name": "@claude-teams/agent-graph", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "lucide-react": ">=0.300.0" + }, + "dependencies": { + "d3-force": "^3.0.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10" + } +} diff --git a/packages/agent-graph/src/canvas/background-layer.ts b/packages/agent-graph/src/canvas/background-layer.ts new file mode 100644 index 00000000..ea181392 --- /dev/null +++ b/packages/agent-graph/src/canvas/background-layer.ts @@ -0,0 +1,158 @@ +/** + * Background rendering: depth star field + hex grid. + * Adapted from agent-flow's background-layer.ts (Apache 2.0). + */ + +import { COLORS, alphaHex } from '../constants/colors'; +import { BACKGROUND } from '../constants/canvas-constants'; + +// ─── Depth Particle (star) ────────────────────────────────────────────────── + +export interface DepthParticle { + x: number; + y: number; + size: number; + brightness: number; + speed: number; + depth: number; +} + +export function createDepthParticles(w: number, h: number): DepthParticle[] { + const particles: DepthParticle[] = []; + for (let i = 0; i < BACKGROUND.starCount; i++) { + particles.push({ + x: Math.random() * w, + y: Math.random() * h, + size: 0.3 + Math.random() * 1.2, + brightness: 0.15 + Math.random() * 0.4, + speed: 0.05 + Math.random() * 0.15, + depth: Math.random(), + }); + } + return particles; +} + +export function updateDepthParticles( + particles: DepthParticle[], + w: number, + h: number, + dt: number, +): void { + for (const p of particles) { + p.y += p.speed * dt * 20; + if (p.y > h + 5) { + p.y = -5; + p.x = Math.random() * w; + } + } +} + +// ─── Background Drawing ───────────────────────────────────────────────────── + +/** + * Draw the space background: void fill + depth stars + optional hex grid. + */ +export function drawBackground( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + particles: DepthParticle[], + camera: { x: number; y: number; zoom: number }, + time: number, + options?: { showHexGrid?: boolean; showStarField?: boolean }, +): void { + const showStars = options?.showStarField ?? true; + const showHex = options?.showHexGrid ?? true; + + // Deep void background + ctx.fillStyle = COLORS.void; + ctx.fillRect(0, 0, w, h); + + // Depth star field + if (showStars) { + for (const p of particles) { + const parallax = 1 - p.depth * 0.3; + const sx = p.x + camera.x * parallax * 0.02; + const sy = p.y + camera.y * parallax * 0.02; + const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01); + const alpha = p.brightness * twinkle; + + ctx.fillStyle = COLORS.holoBright + alphaHex(alpha); + ctx.beginPath(); + ctx.arc( + ((sx % w) + w) % w, + ((sy % h) + h) % h, + p.size, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } + + // Hex grid + if (showHex) { + drawHexGrid(ctx, w, h, camera, time); + } +} + +// ─── Hex Grid ─────────────────────────────────────────────────────────────── + +// Pre-computed hex vertex offsets +const HEX_OFFSETS: [number, number][] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_OFFSETS.push([Math.cos(angle), Math.sin(angle)]); +} + +function drawHexGrid( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + camera: { x: number; y: number; zoom: number }, + time: number, +): void { + const size = BACKGROUND.hexSize; + const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed)); + + // Visible region in world space (expanded a bit for edge cells) + const worldX0 = -camera.x / camera.zoom - size * 2; + const worldY0 = -camera.y / camera.zoom - size * 2; + const worldX1 = (w - camera.x) / camera.zoom + size * 2; + const worldY1 = (h - camera.y) / camera.zoom + size * 2; + + const rowH = size * 1.5; + const colW = size * Math.sqrt(3); + + const rowStart = Math.floor(worldY0 / rowH); + const rowEnd = Math.ceil(worldY1 / rowH); + const colStart = Math.floor(worldX0 / colW); + const colEnd = Math.ceil(worldX1 / colW); + + ctx.save(); + ctx.translate(camera.x, camera.y); + ctx.scale(camera.zoom, camera.zoom); + + ctx.strokeStyle = COLORS.hexGrid + alphaHex(pulse); + ctx.lineWidth = 0.5 / camera.zoom; + + ctx.beginPath(); + for (let row = rowStart; row <= rowEnd; row++) { + for (let col = colStart; col <= colEnd; col++) { + const cx = col * colW + (row % 2 === 0 ? 0 : colW / 2); + const cy = row * rowH; + + for (let i = 0; i < 6; i++) { + const [ox, oy] = HEX_OFFSETS[i]; + const px = cx + ox * size; + const py = cy + oy * size; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + } + } + ctx.stroke(); + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/bloom-renderer.ts b/packages/agent-graph/src/canvas/bloom-renderer.ts new file mode 100644 index 00000000..efbe85a2 --- /dev/null +++ b/packages/agent-graph/src/canvas/bloom-renderer.ts @@ -0,0 +1,70 @@ +/** + * Post-processing bloom effect. + * Adapted from agent-flow's bloom-renderer.ts (Apache 2.0). + * Zero imports — pure Canvas 2D. + */ + +export class BloomRenderer { + #bloomCanvas: HTMLCanvasElement; + #bloomCtx: CanvasRenderingContext2D; + #tempCanvas: HTMLCanvasElement; + #tempCtx: CanvasRenderingContext2D; + #intensity: number; + #w = 0; + #h = 0; + + constructor(intensity = 0.6) { + this.#intensity = intensity; + this.#bloomCanvas = document.createElement('canvas'); + this.#bloomCtx = this.#bloomCanvas.getContext('2d')!; + this.#tempCanvas = document.createElement('canvas'); + this.#tempCtx = this.#tempCanvas.getContext('2d')!; + } + + resize(w: number, h: number): void { + const hw = Math.ceil(w / 2); + const hh = Math.ceil(h / 2); + if (this.#w === hw && this.#h === hh) return; + this.#w = hw; + this.#h = hh; + this.#bloomCanvas.width = hw; + this.#bloomCanvas.height = hh; + this.#tempCanvas.width = hw; + this.#tempCanvas.height = hh; + } + + setIntensity(v: number): void { + this.#intensity = Math.max(0, Math.min(1, v)); + } + + apply(source: HTMLCanvasElement, targetCtx: CanvasRenderingContext2D): void { + if (this.#intensity <= 0 || this.#w === 0) return; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(source, 0, 0, this.#w, this.#h); + + const radii = [8, 6, 4]; + for (const r of radii) { + this.#tempCtx.clearRect(0, 0, this.#w, this.#h); + this.#tempCtx.filter = `blur(${r}px)`; + this.#tempCtx.drawImage(this.#bloomCanvas, 0, 0); + this.#tempCtx.filter = 'none'; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(this.#tempCanvas, 0, 0); + } + + const prevOp = targetCtx.globalCompositeOperation; + const prevAlpha = targetCtx.globalAlpha; + targetCtx.globalCompositeOperation = 'lighter'; + targetCtx.globalAlpha = this.#intensity; + targetCtx.drawImage(this.#bloomCanvas, 0, 0, source.width, source.height); + targetCtx.globalCompositeOperation = prevOp; + targetCtx.globalAlpha = prevAlpha; + } + + [Symbol.dispose](): void { + this.#w = 0; + this.#h = 0; + } +} diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts new file mode 100644 index 00000000..44bdd28d --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -0,0 +1,587 @@ +/** + * Agent (member/lead) node drawing with holographic effects. + * Adapted from agent-flow's draw-agents.ts (Apache 2.0). + * Uses our GraphNode port type instead of agent-flow's Agent type. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getStateColor, alphaHex } from '../constants/colors'; +import { NODE, AGENT_DRAW, CONTEXT_RING, ANIM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache'; + +/** + * Draw all member/lead nodes on the canvas. + */ +export function drawAgents( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'member' && node.kind !== 'lead') continue; + const opacity = getNodeOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember; + const color = node.color ?? getStateColor(node.state); + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + // Depth shadow + drawDepthShadow(ctx, x, y, r); + + // Outer glow + drawGlow(ctx, x, y, r, color); + + // Hexagonal body with interior fill + drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); + + // Avatar: robohash image or fallback letter + drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); + + // Breathing animation + spawn/waiting effects + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); + + // Pending approval indicator: pulsing amber ring + if (node.pendingApproval) { + const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 7); + const ringR = r + 5; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#f59e0b', pulseAlpha); + ctx.lineWidth = 2; + ctx.stroke(); + + // Subtle amber glow + const glowR = r + 12; + const grad = ctx.createRadialGradient(x, y, r, x, y, glowR); + grad.addColorStop(0, hexWithAlpha('#f59e0b', pulseAlpha * 0.25)); + grad.addColorStop(1, 'transparent'); + ctx.beginPath(); + ctx.arc(x, y, glowR, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + } + + // Working indicator: subtle spinning arc when member has active task + if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) { + const ringR = r + 4; + const rotation = time * 1.5; + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(color, 0.4); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + if (node.activeTool) { + drawToolCard(ctx, x, y, r, node.activeTool, time); + } + + // Name + role label (single line: "jack · developer") + const labelText = node.role ? `${node.label} · ${node.role}` : node.label; + drawLabel(ctx, x, y, r, labelText, color); + + // TODO: Context ring disabled — LeadContextUsage.percent is unreliable + // (jumps due to cache_read variance, contextWindow mismatch with actual model). + // Re-enable when we have stable context window data from modelUsage. + // if (node.kind === 'lead' && node.contextUsage != null) { + // drawContextRing(ctx, x, y, r, node.contextUsage, time); + // } + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + +/** + * Draw cross-team ghost nodes — semi-transparent dashed hexagons. + */ +export function drawCrossTeamNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'crossteam') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusCrossTeam; + const color = node.color ?? '#cc88ff'; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = isHovered ? 0.7 : 0.5; + + // Subtle glow + const glowR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r, glowR); + ctx.drawImage(sprite, x - glowR, y - glowR); + + // Dashed hexagon body + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(10, 15, 40, 0.4)'; + ctx.fill(); + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = hexWithAlpha(color, 0.6); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + + // Link icon (two arrows ↔) in center + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(color, 0.8); + ctx.fillText('\u{2194}', x, y); // ↔ + + // Label below + ctx.globalAlpha = 0.7; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(color, 0.7); + ctx.fillText(node.label, x, y + r + 6); + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getNodeOpacity(node: GraphNode): number { + if (node.state === 'terminated' || node.state === 'complete') return 0.3; + if (node.spawnStatus === 'spawning') return 0.85; + if (node.spawnStatus === 'waiting') return 0.7; + if (node.spawnStatus === 'offline') return 0; + return 1; +} + +function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + ctx.shadowBlur = AGENT_DRAW.shadowBlur; + ctx.shadowOffsetX = AGENT_DRAW.shadowOffsetX; + ctx.shadowOffsetY = AGENT_DRAW.shadowOffsetY; + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(0, 0, 0, 0.01)'; + ctx.fill(); + ctx.restore(); +} + +function drawGlow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void { + const outerR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r * 0.5, outerR); + ctx.drawImage(sprite, x - outerR, y - outerR); +} + +function drawHexBody( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, + state: string, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + // Interior fill + drawHexagon(ctx, x, y, r); + ctx.fillStyle = isSelected + ? 'rgba(100, 200, 255, 0.15)' + : COLORS.nodeInterior; + ctx.fill(); + + // Scanline effect + const scanSpeed = state === 'active' || state === 'thinking' || state === 'tool_calling' + ? ANIM.scanline.active + : ANIM.scanline.normal; + const scanY = ((time * scanSpeed) % (r * 2)) - r; + ctx.save(); + drawHexagon(ctx, x, y, r); + ctx.clip(); + const grad = ctx.createLinearGradient( + x, + y + scanY - AGENT_DRAW.scanlineHalfH, + x, + y + scanY + AGENT_DRAW.scanlineHalfH, + ); + grad.addColorStop(0, hexWithAlpha(color, 0)); + grad.addColorStop(0.5, hexWithAlpha(color, 0.13)); + grad.addColorStop(1, hexWithAlpha(color, 0)); + ctx.fillStyle = grad; + ctx.fillRect(x - r, y + scanY - AGENT_DRAW.scanlineHalfH, r * 2, AGENT_DRAW.scanlineHalfH * 2); + ctx.restore(); + + // Border + drawHexagon(ctx, x, y, r); + ctx.strokeStyle = hexWithAlpha(color, isHovered ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); +} + +function truncateCardText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let out = text; + while (out.length > 1 && ctx.measureText(`${out}...`).width > maxWidth) { + out = out.slice(0, -1); + } + return `${out}...`; +} + +function drawToolCard( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + tool: NonNullable, + time: number, +): void { + const labelBase = tool.preview ? `${tool.name}: ${tool.preview}` : tool.name; + const labelText = + tool.state === 'error' + ? `${tool.name}: failed` + : tool.state === 'complete' && tool.resultPreview + ? `${tool.name}: ${tool.resultPreview}` + : labelBase; + + ctx.save(); + ctx.font = '8px monospace'; + const truncated = truncateCardText(ctx, labelText, 104); + const textWidth = ctx.measureText(truncated).width; + const cardW = Math.max(62, Math.min(124, textWidth + 24)); + const cardH = 18; + const cardX = x - cardW / 2; + const cardY = y - r - cardH - 10; + const accent = + tool.state === 'error' + ? COLORS.error + : tool.state === 'complete' + ? COLORS.complete + : COLORS.tool_calling; + + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, 4); + ctx.fillStyle = tool.state === 'running' ? 'rgba(10, 15, 30, 0.85)' : 'rgba(10, 15, 30, 0.78)'; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(accent, 0.7); + ctx.lineWidth = 1; + ctx.stroke(); + + const indicatorX = cardX + 10; + const indicatorY = cardY + cardH / 2; + + if (tool.state === 'running') { + ctx.beginPath(); + ctx.arc( + indicatorX, + indicatorY, + 4.5, + time * 3, + time * 3 + Math.PI * 1.2, + ); + ctx.strokeStyle = accent; + ctx.lineWidth = 1.4; + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(indicatorX, indicatorY, 2.5, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + } + + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = accent; + ctx.fillText(truncated, indicatorX + 8, indicatorY); + ctx.restore(); +} + +function drawBreathing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + state: string, + time: number, + spawnStatus?: GraphNode['spawnStatus'], +): void { + // Spawning: bright animated double ring + radial glow + if (spawnStatus === 'spawning') { + const ringR = r + AGENT_DRAW.orbitParticleOffset; + const rotation = time * ANIM.orbitSpeed * 2; + + // Outer glow pulse + const glowAlpha = 0.15 + 0.1 * Math.sin(time * 3); + const grad = ctx.createRadialGradient(x, y, r, x, y, ringR + 15); + grad.addColorStop(0, hexWithAlpha(COLORS.holoBase, glowAlpha)); + grad.addColorStop(1, hexWithAlpha(COLORS.holoBase, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(x, y, ringR + 15, 0, Math.PI * 2); + ctx.fill(); + + // Primary spinning arc + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.2); + ctx.strokeStyle = hexWithAlpha(COLORS.holoBase, 0.7); + ctx.lineWidth = 2.5; + ctx.setLineDash([8, 5]); + ctx.stroke(); + + // Secondary counter-rotating arc + ctx.beginPath(); + ctx.arc(x, y, ringR + 5, -rotation * 0.7, -rotation * 0.7 + Math.PI * 0.6); + ctx.strokeStyle = hexWithAlpha(COLORS.holoBase, 0.3); + ctx.lineWidth = 1; + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + // "connecting" label below name + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2)); + ctx.fillText('connecting...', x, y + r + AGENT_DRAW.labelYOffset + 14); + return; + } + + // Waiting: pulsing glow + hex outline + "waiting" label + if (spawnStatus === 'waiting') { + const pulse = 0.15 + 0.15 * Math.sin(time * AGENT_DRAW.waitingBreatheSpeed); + + // Soft glow + const grad = ctx.createRadialGradient(x, y, r * 0.5, x, y, r + 10); + grad.addColorStop(0, hexWithAlpha(COLORS.waiting, pulse * 0.5)); + grad.addColorStop(1, hexWithAlpha(COLORS.waiting, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(x, y, r + 10, 0, Math.PI * 2); + ctx.fill(); + + // Pulsing hex outline + drawHexagon(ctx, x, y, r + AGENT_DRAW.outerRingOffset); + ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse); + ctx.lineWidth = 1.5; + ctx.stroke(); + + // "waiting" label + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5)); + ctx.fillText('waiting...', x, y + r + AGENT_DRAW.labelYOffset + 14); + return; + } + + const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling'; + const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed; + const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp; + const breathe = 1 + amp * Math.sin(time * speed); + + if (isActive) { + // Orbiting particles for active agents + const orbitR = r + AGENT_DRAW.orbitParticleOffset; + const count = 4; + for (let i = 0; i < count; i++) { + const angle = time * ANIM.orbitSpeed + (Math.PI * 2 * i) / count; + const px = x + orbitR * breathe * Math.cos(angle); + const py = y + orbitR * breathe * Math.sin(angle); + ctx.fillStyle = COLORS.holoBright + '80'; + ctx.beginPath(); + ctx.arc(px, py, AGENT_DRAW.orbitParticleSize, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // Subtle pulsing glow ring for idle agents + const pulseAlpha = 0.04 + 0.04 * Math.sin(time * speed); + ctx.beginPath(); + ctx.arc(x, y, r + 2, 0, Math.PI * 2); + ctx.strokeStyle = COLORS.holoBase + alphaHex(pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } +} + +// ─── Avatar image cache with LRU eviction ─────────────────────────────────── + +const AVATAR_CACHE_MAX = 100; +const avatarCache = new Map(); +const avatarLoading = new Set(); + +function getAvatarImage(url: string): HTMLImageElement | null { + const cached = avatarCache.get(url); + if (cached) { + // Move to end (most recently used) + avatarCache.delete(url); + avatarCache.set(url, cached); + return cached; + } + if (avatarLoading.has(url)) return null; + + avatarLoading.add(url); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + // Evict oldest entry if over limit + if (avatarCache.size >= AVATAR_CACHE_MAX) { + const first = avatarCache.keys().next().value; + if (first != null) avatarCache.delete(first); + } + avatarCache.set(url, img); + avatarLoading.delete(url); + }; + img.onerror = () => { + avatarLoading.delete(url); + }; + img.src = url; + return null; +} + +function drawAvatar( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + name: string, + color: string, + isLead: boolean, + avatarUrl?: string, +): void { + const avatarR = r * 0.6; + + // Try to draw avatar image + if (avatarUrl) { + const img = getAvatarImage(avatarUrl); + if (img) { + ctx.save(); + // Clip to circle inside hexagon + ctx.beginPath(); + ctx.arc(x, y, avatarR, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, x - avatarR, y - avatarR, avatarR * 2, avatarR * 2); + ctx.restore(); + return; + } + } + + // Fallback: first letter + const letter = name.charAt(0).toUpperCase(); + const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7); + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(color, 0.9); + ctx.fillText(letter, x, y + 1); +} + +function drawLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + label: string, + color: string, +): void { + const labelY = y + r + AGENT_DRAW.labelYOffset; + ctx.font = '9px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = color; + ctx.fillText(label, x, labelY); +} + +/** + * Draw context usage ring around lead node. + */ +export function drawContextRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + usage: number, + time: number, +): void { + const ringR = r + CONTEXT_RING.ringOffset; + const startAngle = -Math.PI / 2; + const endAngle = startAngle + Math.PI * 2 * Math.min(1, usage); + + // Background ring + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha(COLORS.holoBright, 0.08); + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Usage arc + let ringColor: string = COLORS.complete; + if (usage > CONTEXT_RING.criticalThreshold) { + ringColor = COLORS.error; + } else if (usage > CONTEXT_RING.warningThreshold) { + ringColor = COLORS.waiting; + } + + // Pulsing glow for high usage + if (usage > CONTEXT_RING.warningThreshold) { + const pulse = 0.5 + 0.5 * Math.sin(time * 3); + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor + alphaHex(0.3 * pulse); + ctx.lineWidth = CONTEXT_RING.ringWidth + CONTEXT_RING.glowPadding; + ctx.stroke(); + } + + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor; + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Percentage label — always show for lead + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = ringColor; + ctx.fillText(`${Math.round(usage * 100)}% context`, x, y - r - CONTEXT_RING.percentYOffset); +} + +function drawSelectionRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, +): void { + drawHexagon(ctx, x, y, r + 4); + ctx.strokeStyle = hexWithAlpha(color, 0.67); + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); +} diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts new file mode 100644 index 00000000..9982168b --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -0,0 +1,219 @@ +/** + * Edge drawing with tapered bezier curves and gradients. + * Adapted from agent-flow's draw-edges.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; + +// ─── Edge Type → Color/Width Mapping ──────────────────────────────────────── + +const EDGE_STYLES: Record = { + 'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild }, + ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership }, + blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] }, + related: { color: COLORS.edgeRelated, ...BEAM.related, dash: [4, 4] }, + message: { color: COLORS.edgeMessage, ...BEAM.message }, +}; + +// ─── Bezier Utilities ─────────────────────────────────────────────────────── + +export interface ControlPoints { + cp1x: number; + cp1y: number; + cp2x: number; + cp2y: number; +} + +export function computeControlPoints( + x1: number, + y1: number, + x2: number, + y2: number, +): ControlPoints { + const dx = x2 - x1; + const dy = y2 - y1; + const nx = -dy * BEAM.curvature; + const ny = dx * BEAM.curvature; + return { + cp1x: x1 + dx * BEAM.cp1 + nx, + cp1y: y1 + dy * BEAM.cp1 + ny, + cp2x: x1 + dx * BEAM.cp2 + nx, + cp2y: y1 + dy * BEAM.cp2 + ny, + }; +} + +/** + * Evaluate a cubic bezier at parameter t. + */ +export function bezierPoint( + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + t: number, +): { x: number; y: number } { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * x1 + 3 * uu * t * cp.cp1x + 3 * u * tt * cp.cp2x + ttt * x2, + y: uuu * y1 + 3 * uu * t * cp.cp1y + 3 * u * tt * cp.cp2y + ttt * y2, + }; +} + +// ─── Draw All Edges ───────────────────────────────────────────────────────── + +export function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + nodeMap: Map, + _time: number, + hasActiveParticles: Set, +): void { + for (const edge of edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; + const isActive = hasActiveParticles.has(edge.id); + // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave + const alpha = isActive + ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) + : BEAM.idleAlpha; + + if (alpha < MIN_VISIBLE_OPACITY) continue; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + + ctx.save(); + ctx.globalAlpha = alpha; + + // Subtle glow pass when edge has active particles + if (isActive) { + ctx.shadowColor = edge.color ?? style.color; + ctx.shadowBlur = 12; + } + + // Draw tapered bezier + drawTaperedBezier( + ctx, + source.x, + source.y, + cp, + target.x, + target.y, + style.startW, + style.endW, + edge.color ?? style.color, + style.dash, + ); + + // Arrow for blocking edges + if (edge.type === 'blocking') { + drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha); + } + + ctx.restore(); + } +} + +// ─── Tapered Bezier ───────────────────────────────────────────────────────── + +function drawTaperedBezier( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + startW: number, + endW: number, + color: string, + dash?: number[], +): void { + if (dash) { + // Dashed edges use stroke, not fill polygon + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2); + ctx.strokeStyle = color; + ctx.lineWidth = (startW + endW) / 2; + ctx.setLineDash(dash); + ctx.stroke(); + ctx.setLineDash([]); + return; + } + + // Build polygon outline for tapered width + const segments = BEAM.segments; + const leftPoints: { x: number; y: number }[] = []; + const rightPoints: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const pos = bezierPoint(x1, y1, cp, x2, y2, t); + const w = startW + (endW - startW) * t; + + // Normal perpendicular + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(x1, y1, cp, x2, y2, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + leftPoints.push({ x: pos.x + nx * w * 0.5, y: pos.y + ny * w * 0.5 }); + rightPoints.push({ x: pos.x - nx * w * 0.5, y: pos.y - ny * w * 0.5 }); + } + + ctx.beginPath(); + ctx.moveTo(leftPoints[0].x, leftPoints[0].y); + for (let i = 1; i < leftPoints.length; i++) { + ctx.lineTo(leftPoints[i].x, leftPoints[i].y); + } + for (let i = rightPoints.length - 1; i >= 0; i--) { + ctx.lineTo(rightPoints[i].x, rightPoints[i].y); + } + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); +} + +// ─── Arrow Head ───────────────────────────────────────────────────────────── + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + cp: ControlPoints, + x2: number, + y2: number, + color: string, + alpha: number, +): void { + // Compute direction at t=1 + const dx = x2 - cp.cp2x; + const dy = y2 - cp.cp2y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len; + const uy = dy / len; + const arrowSize = 8; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5); + ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-effects.ts b/packages/agent-graph/src/canvas/draw-effects.ts new file mode 100644 index 00000000..4bdcd8e0 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-effects.ts @@ -0,0 +1,178 @@ +/** + * Visual effects: spawn animation, completion shatter, spawn ring. + * Adapted from agent-flow's draw-effects.ts (Apache 2.0). + */ + +import { alphaHex } from '../constants/colors'; +import { SPAWN_FX, COMPLETE_FX } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; + +// ─── Effect Type ──────────────────────────────────────────────────────────── + +export interface VisualEffect { + type: 'spawn' | 'complete' | 'shatter'; + x: number; + y: number; + color: string; + age: number; + duration: number; + /** Node radius for scaling the effect */ + nodeRadius?: number; + particles?: ShatterParticle[]; +} + +interface ShatterParticle { + angle: number; + speed: number; + size: number; +} + +/** + * Create a spawn effect at position. + */ +export function createSpawnEffect(x: number, y: number, color: string, nodeRadius?: number): VisualEffect { + return { type: 'spawn', x, y, color, age: 0, duration: 0.8, nodeRadius }; +} + +/** + * Create a completion shatter effect at position. + */ +export function createCompleteEffect(x: number, y: number, color: string): VisualEffect { + const particles: ShatterParticle[] = []; + for (let i = 0; i < 12; i++) { + particles.push({ + angle: (Math.PI * 2 * i) / 12 + (Math.random() - 0.5) * 0.3, + speed: 30 + Math.random() * 60, + size: 1 + Math.random() * 2, + }); + } + return { type: 'shatter', x, y, color, age: 0, duration: 0.8, particles }; +} + +// ─── Draw Effects ─────────────────────────────────────────────────────────── + +export function drawEffects( + ctx: CanvasRenderingContext2D, + effects: VisualEffect[], +): void { + for (const fx of effects) { + const progress = fx.age / fx.duration; + if (progress >= 1) continue; + + switch (fx.type) { + case 'spawn': + drawSpawnEffect(ctx, fx, progress); + break; + case 'complete': + drawCompleteEffect(ctx, fx, progress); + break; + case 'shatter': + drawShatterEffect(ctx, fx, progress); + break; + } + } +} + +// ─── Spawn: expanding hex ring + white flash ──────────────────────────────── + +function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = SPAWN_FX.maxAlpha * (1 - progress); + const baseR = fx.nodeRadius ?? SPAWN_FX.ringStart; + const ringR = baseR + SPAWN_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding hex ring + drawHexagon(ctx, fx.x, fx.y, ringR); + ctx.strokeStyle = fx.color; + ctx.lineWidth = 2 * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < SPAWN_FX.flashThreshold) { + const flashProgress = progress / SPAWN_FX.flashThreshold; + const flashR = SPAWN_FX.flashBaseRadius * (1 - flashProgress) + SPAWN_FX.flashMinRadius; + ctx.fillStyle = '#ffffff' + alphaHex(SPAWN_FX.flashAlpha * (1 - flashProgress)); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, flashR, 0, Math.PI * 2); + ctx.fill(); + } + + // Scatter particles + for (let i = 0; i < SPAWN_FX.particleCount; i++) { + const angle = (Math.PI * 2 * i) / SPAWN_FX.particleCount; + const dist = ringR * 0.8 * progress; + const px = fx.x + Math.cos(angle) * dist; + const py = fx.y + Math.sin(angle) * dist; + ctx.fillStyle = fx.color + alphaHex(alpha * 0.6); + ctx.beginPath(); + ctx.arc(px, py, SPAWN_FX.particleSize * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Complete: white flash + expanding ring ───────────────────────────────── + +function drawCompleteEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = COMPLETE_FX.maxAlpha * (1 - progress); + const ringR = COMPLETE_FX.ringStart + COMPLETE_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding ring + ctx.beginPath(); + ctx.arc(fx.x, fx.y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = fx.color; + ctx.lineWidth = COMPLETE_FX.lineWidthMax * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < COMPLETE_FX.flashThreshold) { + const flashAlpha = COMPLETE_FX.flashAlpha * (1 - progress / COMPLETE_FX.flashThreshold); + ctx.fillStyle = '#ffffff' + alphaHex(flashAlpha); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, COMPLETE_FX.flashRadius * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Shatter: particles scatter outward ───────────────────────────────────── + +function drawShatterEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + if (!fx.particles) return; + + const alpha = 1 - progress; + ctx.save(); + ctx.globalAlpha = alpha; + + for (const p of fx.particles) { + const dist = p.speed * progress; + const px = fx.x + Math.cos(p.angle) * dist; + const py = fx.y + Math.sin(p.angle) * dist; + const size = p.size * (1 - progress * 0.5); + + // Glow + const grad = ctx.createRadialGradient(px, py, 0, px, py, size * 3); + grad.addColorStop(0, fx.color + alphaHex(alpha * 0.4)); + grad.addColorStop(1, hexWithAlpha(fx.color, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, size * 3, 0, Math.PI * 2); + ctx.fill(); + + // Core + ctx.fillStyle = fx.color; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-misc.ts b/packages/agent-graph/src/canvas/draw-misc.ts new file mode 100644 index 00000000..1a60831e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-misc.ts @@ -0,0 +1,65 @@ +/** + * Utility drawing functions. + * Adapted from agent-flow's draw-misc.ts (Apache 2.0). + */ + +import { measureTextCached } from './render-cache'; + +/** + * Truncate text to fit within maxWidth, appending "..." if needed. + * Uses binary search for efficiency. + */ +export function truncateText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + font: string, +): string { + if (measureTextCached(ctx, font, text) <= maxWidth) return text; + + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (measureTextCached(ctx, font, text.slice(0, mid) + '...') <= maxWidth) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo > 0 ? text.slice(0, lo) + '...' : '...'; +} + +// Pre-computed hex vertex unit offsets (avoids cos/sin per call) +const HEX_COS: number[] = []; +const HEX_SIN: number[] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_COS.push(Math.cos(angle)); + HEX_SIN.push(Math.sin(angle)); +} + +/** + * Draw a regular hexagon path centered at (x, y) with given radius. + */ +export function drawHexagon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + radius * HEX_COS[0], y + radius * HEX_SIN[0]); + ctx.lineTo(x + radius * HEX_COS[1], y + radius * HEX_SIN[1]); + ctx.lineTo(x + radius * HEX_COS[2], y + radius * HEX_SIN[2]); + ctx.lineTo(x + radius * HEX_COS[3], y + radius * HEX_SIN[3]); + ctx.lineTo(x + radius * HEX_COS[4], y + radius * HEX_SIN[4]); + ctx.lineTo(x + radius * HEX_COS[5], y + radius * HEX_SIN[5]); + ctx.closePath(); +} + +/** + * SVG path data for the Claude spark logo (256×256 viewbox). + */ +export const CLAUDE_SPARK_D = + 'M128,8C60.6,8,8,60.6,8,128s52.6,120,120,120s120-52.6,120-120S195.4,8,128,8z M161.6,169.6 c-4.8,8-16,10.8-24,6l-9.6-5.6l-9.6,5.6c-8,4.8-19.2,1.6-24-6c-4.8-8-1.6-19.2,6-24l9.6-5.6v-11.2l-9.6-5.6 c-8-4.8-10.8-16-6-24c4.8-8,16-10.8,24-6l9.6,5.6l9.6-5.6c8-4.8,19.2-1.6,24,6c4.8,8,1.6,19.2-6,24l-9.6,5.6v11.2l9.6,5.6 C163.2,150.4,166.4,161.6,161.6,169.6z'; diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts new file mode 100644 index 00000000..ce222779 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -0,0 +1,219 @@ +/** + * Particle animation along edges. + * Adapted from agent-flow's draw-particles.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { PARTICLE_DRAW, BEAM } from '../constants/canvas-constants'; +import { bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +import { getGlowSprite, hexWithAlpha } from './render-cache'; + +/** + * Build a lookup from edge.id → edge for fast particle→edge resolution. + */ +export function buildEdgeMap(edges: GraphEdge[]): Map { + const map = new Map(); + for (const e of edges) map.set(e.id, e); + return map; +} + +/** + * Draw all active particles along their edges. + */ +export function drawParticles( + ctx: CanvasRenderingContext2D, + particles: GraphParticle[], + edgeMap: Map, + nodeMap: Map, + time: number, +): void { + for (const p of particles) { + const edge = edgeMap.get(p.edgeId); + if (!edge) continue; + + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + // Reverse: swap source/target for particles going in opposite direction + const from = p.reverse ? target : source; + const to = p.reverse ? source : target; + + const cp = computeControlPoints(from.x!, from.y!, to.x!, to.y!); + const color = p.color || COLORS.message; + const baseSize = (p.size ?? 1) * 3; + // Differentiate visual by particle kind + const size = p.kind === 'spawn' ? baseSize * 1.5 + : p.kind === 'task_comment' ? baseSize * 1.15 + : p.kind === 'review_request' || p.kind === 'review_response' ? baseSize * 1.2 + : baseSize; + + // Wobble offset for organic look + const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1; + const wobbleAmp = BEAM.wobble.amp; + + drawParticleTrail(ctx, from, to, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); + drawParticleCore(ctx, from, to, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); + + // Label + if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) { + const pos = getWobbledPosition(from, to, cp, p.progress, wobbleAmp, phaseOffset, time); + ctx.font = `${PARTICLE_DRAW.labelFontSize}px monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = hexWithAlpha(color, 0.56); + ctx.fillText(p.label, pos.x, pos.y + PARTICLE_DRAW.labelYOffset); + } + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getWobbledPosition( + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + t: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): { x: number; y: number } { + const pos = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, t); + + // Perpendicular wobble + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + const wobble = Math.sin(t * BEAM.wobble.freq + time * BEAM.wobble.timeFreq + phaseOffset) * wobbleAmp; + return { + x: pos.x + nx * wobble, + y: pos.y + ny * wobble, + }; +} + +function drawParticleTrail( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, + kind: GraphParticle['kind'], +): void { + const trailSegments = kind === 'task_comment' ? 4 : 6; + const trailStep = BEAM.wobble.trailOffset / trailSegments; + + for (let i = trailSegments; i >= 1; i--) { + const t = Math.max(0, progress - trailStep * i); + const pos = getWobbledPosition(source, target, cp, t, wobbleAmp, phaseOffset, time); + const alpha = (1 - i / trailSegments) * 0.3; + const trailSize = size * (1 - i / trailSegments) * 0.5; + + if (kind === 'task_comment') { + ctx.strokeStyle = hexWithAlpha(color, alpha); + ctx.lineWidth = Math.max(0.8, trailSize * 0.45); + ctx.beginPath(); + ctx.arc(pos.x, pos.y, trailSize * 1.15, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.fillStyle = hexWithAlpha(color, alpha); + ctx.beginPath(); + ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2); + ctx.fill(); + } + } +} + +function drawParticleCore( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, + kind: GraphParticle['kind'], +): void { + const pos = getWobbledPosition(source, target, cp, progress, wobbleAmp, phaseOffset, time); + + // Glow sprite + const glowR = PARTICLE_DRAW.glowRadius; + const sprite = getGlowSprite(color, glowR, kind === 'task_comment' ? 0.28 : 0.4, 0); + ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR); + + if (kind === 'task_comment') { + drawCommentBubble(ctx, pos.x, pos.y, size, color); + return; + } + + // Core dot + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size, 0, Math.PI * 2); + ctx.fill(); + + // Highlight + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2); + ctx.fill(); +} + +/** + * Draw a speech-bubble icon for comment particles. + * Rounded rect body + small triangular tail at bottom-left. + */ +function drawCommentBubble( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + color: string, +): void { + const w = size * 2.4; + const h = size * 1.8; + const r = size * 0.4; // corner radius + const x = cx - w / 2; + const y = cy - h / 2; + + // Bubble body + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + ctx.fill(); + + // Tail (small triangle at bottom-left) + const tailX = x + w * 0.25; + const tailY = y + h; + ctx.beginPath(); + ctx.moveTo(tailX, tailY - 1); + ctx.lineTo(tailX - size * 0.4, tailY + size * 0.5); + ctx.lineTo(tailX + size * 0.4, tailY - 1); + ctx.closePath(); + ctx.fill(); + + // Inner dots (three small dots to suggest text) + ctx.fillStyle = '#ffffff'; + const dotR = size * 0.18; + const dotY = cy - size * 0.05; + const gap = size * 0.5; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.arc(cx + i * gap, dotY, dotR, 0, Math.PI * 2); + ctx.fill(); + } +} diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts new file mode 100644 index 00000000..25485126 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -0,0 +1,65 @@ +/** + * Process node rendering — small circles for running processes. + * NEW — not from agent-flow. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { NODE } from '../constants/canvas-constants'; +import { hexWithAlpha, getGlowSprite } from './render-cache'; + +/** + * Draw all process nodes as small circles. + */ +export function drawProcesses( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'process') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = 0.8; + + // Glow — use cached sprite instead of createRadialGradient per frame + const procColor = node.color ?? COLORS.tool_calling; + const glowSprite = getGlowSprite(procColor, r * 2, 0.19, 0); + ctx.drawImage(glowSprite, x - r * 2, y - r * 2); + + // Body + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = isSelected ? COLORS.cardBgSelected : COLORS.cardBg; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + // Spinning ring for active processes + const spinAngle = time * 2; + ctx.beginPath(); + ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Label + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label; + ctx.fillText(label, x, y + r + 4); + + ctx.restore(); + } +} diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts new file mode 100644 index 00000000..04184bf8 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -0,0 +1,266 @@ +/** + * Task pill-shaped node rendering. + * NEW — not from agent-flow. Custom renderer for our task nodes. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; +import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; +import { truncateText } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; +import type { KanbanZoneInfo } from '../layout/kanbanLayout'; + +/** + * Draw all task nodes as pill-shaped cards. + */ +export function drawTasks( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'task') continue; + + const opacity = getTaskOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + drawTaskPill(ctx, x, y, node, time, isSelected, isHovered); + + ctx.restore(); + } +} + +// ─── Private ──────────────────────────────────────────────────────────────── + +function getTaskOpacity(_node: GraphNode): number { + if (_node.taskStatus === 'deleted') return 0; + return 1; +} + +function drawTaskPill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + node: GraphNode, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + const w = TASK_PILL.width; + const h = TASK_PILL.height; + const r = TASK_PILL.borderRadius; + const halfW = w / 2; + const halfH = h / 2; + + const statusColor = getTaskStatusColor(node.taskStatus); + const reviewColor = getReviewStateColor(node.reviewState); + + // Pulse only for active work — completed + approved = static + const needsAttention = + (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || + node.reviewState === 'review' || + node.reviewState === 'needsFix' || + (node.needsClarification != null); + const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; + const breathe = needsAttention && !isFinished + ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) + : 1; + const scale = breathe; + + ctx.save(); + ctx.translate(x, y); + ctx.scale(scale, scale); + + // Shadow — stronger for attention tasks, red for blocked + ctx.shadowColor = node.isBlocked + ? hexWithAlpha(COLORS.edgeBlocking, 0.3) + : hexWithAlpha(statusColor, 0.25); + ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4; + + // Background fill + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + ctx.fillStyle = isSelected + ? COLORS.cardBgSelected + : isHovered + ? 'rgba(15, 20, 40, 0.7)' + : COLORS.cardBg; + ctx.fill(); + ctx.shadowBlur = 0; + + // Border — red for blocked tasks + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + if (node.isBlocked) { + ctx.strokeStyle = hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7); + ctx.lineWidth = isSelected ? 2.5 : 1.8; + } else { + ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + } + ctx.stroke(); + + // Blocked indicator — red left stripe + if (node.isBlocked) { + ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]); + ctx.fill(); + } + + // Review state overlay border — pulsing for review/needsFix, STATIC for approved + if (reviewColor !== 'transparent') { + ctx.beginPath(); + ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); + const reviewAlpha = node.reviewState === 'approved' + ? 0.6 // static — no pulse + : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix + ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + // Clarification warning indicator + if (node.needsClarification) { + const pulseAlpha = 0.4 + 0.4 * Math.sin(time * 4); + ctx.beginPath(); + ctx.roundRect(-halfW - 2, -halfH - 2, w + 4, h + 4, r + 2); + ctx.strokeStyle = hexWithAlpha(COLORS.error, pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Subject (main title — large) + if (node.sublabel) { + ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.textPrimary; + const textX = -halfW + 10; + const maxW = w - 18; + const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); + ctx.fillText(subject, textX, -4); + } + + // Display ID (secondary — small) + const displayId = node.displayId ?? node.label; + ctx.font = `${TASK_PILL.subjectFontSize}px monospace`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.textDim; + ctx.fillText(displayId, -halfW + 10, 8); + + // Approved badge: checkmark at right side + if (node.reviewState === 'approved') { + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.reviewApproved; + ctx.fillText('\u2713', halfW - 8, 0); // ✓ + } + + // Comment count badge — on the bottom-right border edge, 1.5x bigger + if (node.totalCommentCount && node.totalCommentCount > 0) { + const badgeX = halfW - 6; + const badgeY = halfH; + + // Speech bubble background + const bw = 20; + const bh = 15; + ctx.fillStyle = hexWithAlpha('#aaeeff', 0.85); + ctx.beginPath(); + ctx.roundRect(badgeX - bw / 2, badgeY - bh / 2, bw, bh, 3); + ctx.fill(); + // Tail pointing up-left + ctx.beginPath(); + ctx.moveTo(badgeX - 5, badgeY + bh / 2); + ctx.lineTo(badgeX - 9, badgeY + bh / 2 + 5); + ctx.lineTo(badgeX - 1, badgeY + bh / 2); + ctx.closePath(); + ctx.fill(); + + // Total count inside bubble + ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#0a0f1e'; + ctx.fillText(String(node.totalCommentCount), badgeX, badgeY + 0.5); + + // Unread count badge (blue circle, top-right of bubble) + if (node.unreadCommentCount && node.unreadCommentCount > 0) { + const dotX = badgeX + bw / 2 + 1; + const dotY = badgeY - bh / 2 - 1; + ctx.fillStyle = '#3b82f6'; + ctx.beginPath(); + ctx.arc(dotX, dotY, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.font = 'bold 8px monospace'; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(node.unreadCommentCount), dotX, dotY + 0.5); + } + } + + ctx.restore(); +} + +/** + * Draw kanban column headers above task columns. + */ +export function drawColumnHeaders( + ctx: CanvasRenderingContext2D, + zones: KanbanZoneInfo[], +): void { + for (const zone of zones) { + // Section header for unassigned tasks — larger, centered above all columns + if (zone.ownerId === '__unassigned__') { + ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = hexWithAlpha(COLORS.taskPending, 0.5); + const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) - 16; + ctx.fillText('Unassigned', zone.ownerX, labelY); + + // Overflow badge + for (const header of zone.headers) { + if (header.overflowCount > 0) { + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(header.color, 0.45); + ctx.fillText(`+${header.overflowCount} more`, header.x, header.overflowY + 4); + } + } + continue; + } + + for (const header of zone.headers) { + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = hexWithAlpha(header.color, 0.6); + ctx.fillText(header.label, header.x, header.y - 2); + + // Overflow badge: "+N more" + if (header.overflowCount > 0) { + const badgeText = `+${header.overflowCount} more`; + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(header.color, 0.45); + ctx.fillText(badgeText, header.x, header.overflowY + 4); + } + } + } +} diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts new file mode 100644 index 00000000..8e77998e --- /dev/null +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -0,0 +1,67 @@ +/** + * Hit detection — determine what the user clicked/hovered in world space. + * Adapted from agent-flow's hit-detection.ts (Apache 2.0). + */ + +import type { GraphNode } from '../ports/types'; +import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +/** + * Find the node at the given world-space coordinates. + * Returns node ID or null. + * Priority: lead > member > task > process. + */ +export function findNodeAt( + worldX: number, + worldY: number, + nodes: GraphNode[], +): string | null { + // Check in reverse priority order, return last match (highest priority wins) + let hit: string | null = null; + + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + + switch (node.kind) { + case 'lead': + case 'member': { + const r = (node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember) + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + hit = node.id; + // Lead has highest priority, return immediately + if (node.kind === 'lead') return hit; + } + break; + } + case 'task': { + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + if ( + worldX >= x - halfW && + worldX <= x + halfW && + worldY >= y - halfH && + worldY <= y + halfH + ) { + hit = node.id; + } + break; + } + case 'process': + case 'crossteam': { + const r = (node.kind === 'crossteam' ? NODE.radiusCrossTeam : NODE.radiusProcess) + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + // Only override if no member/lead already hit + if (!hit) hit = node.id; + } + break; + } + } + } + + return hit; +} diff --git a/packages/agent-graph/src/canvas/index.ts b/packages/agent-graph/src/canvas/index.ts new file mode 100644 index 00000000..82d8def4 --- /dev/null +++ b/packages/agent-graph/src/canvas/index.ts @@ -0,0 +1,11 @@ +export { drawAgents, drawContextRing } from './draw-agents'; +export { drawEdges, bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +export { drawParticles, buildEdgeMap } from './draw-particles'; +export { drawEffects, createSpawnEffect, createCompleteEffect, type VisualEffect } from './draw-effects'; +export { drawTasks } from './draw-tasks'; +export { drawProcesses } from './draw-processes'; +export { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from './background-layer'; +export { BloomRenderer } from './bloom-renderer'; +export { findNodeAt } from './hit-detection'; +export { truncateText, drawHexagon, CLAUDE_SPARK_D } from './draw-misc'; +export { getGlowSprite, getAgentGlowSprite, measureTextCached } from './render-cache'; diff --git a/packages/agent-graph/src/canvas/render-cache.ts b/packages/agent-graph/src/canvas/render-cache.ts new file mode 100644 index 00000000..7336d497 --- /dev/null +++ b/packages/agent-graph/src/canvas/render-cache.ts @@ -0,0 +1,139 @@ +/** + * Pre-rendered sprite cache for Canvas 2D glow effects. + * Adapted from agent-flow (Apache 2.0). + */ + +const glowCache = new Map(); +const textCache = new Map(); +const TEXT_CACHE_LIMIT = 2000; + +// ─── Color resolution: named colors → hex ─────────────────────────────────── + +let _resolverCtx: CanvasRenderingContext2D | null = null; +const _hexCache = new Map(); + +/** + * Ensure a color string is in #rrggbb hex format. + * Resolves CSS named colors (purple → #800080), shorthand (#abc → #aabbcc). + */ +function ensureHex(color: string): string { + if (color.startsWith('#') && color.length === 7) return color; + + let hex = _hexCache.get(color); + if (hex) return hex; + + if (color.startsWith('#') && color.length === 4) { + hex = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; + } else { + // Resolve named/rgb/hsl colors via canvas + _resolverCtx ??= document.createElement('canvas').getContext('2d')!; + _resolverCtx.fillStyle = '#000000'; + _resolverCtx.fillStyle = color; + hex = _resolverCtx.fillStyle; // always returns #rrggbb + } + + _hexCache.set(color, hex); + return hex; +} + +/** Build a hex color with alpha: "#rrggbbaa" — cached for repeated calls */ +const _hexAlphaCache = new Map(); +function hexWithAlpha(color: string, alpha: number): string { + // Quantize alpha to 1/255 steps for cache hit rate + const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255); + const key = `${color}|${a}`; + let result = _hexAlphaCache.get(key); + if (result) return result; + result = ensureHex(color) + alphaHex(a / 255); + _hexAlphaCache.set(key, result); + if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth + return result; +} + +// Reuse alpha hex LUT from colors.ts (DRY — single source) +import { alphaHex } from '../constants/colors'; + +// ─── Glow Sprite Cache ────────────────────────────────────────────────────── + +/** + * Get or create a pre-rendered radial gradient glow sprite. + */ +export function getGlowSprite( + color: string, + radius: number, + innerAlpha: number, + outerAlpha: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `${hex}|${radius}|${innerAlpha}|${outerAlpha}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(radius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, radius); + grad.addColorStop(0, hexWithAlpha(hex, innerAlpha)); + grad.addColorStop(1, hexWithAlpha(hex, outerAlpha)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Get or create a pre-rendered agent glow sprite (inner + outer radius). + */ +export function getAgentGlowSprite( + color: string, + innerRadius: number, + outerRadius: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `agent|${hex}|${innerRadius}|${outerRadius}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(outerRadius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, innerRadius, cx, cx, outerRadius); + grad.addColorStop(0, hexWithAlpha(hex, 0.25)); + grad.addColorStop(0.5, hexWithAlpha(hex, 0.08)); + grad.addColorStop(1, hexWithAlpha(hex, 0)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Cached text width measurement. + */ +export function measureTextCached(ctx: CanvasRenderingContext2D, font: string, text: string): number { + const key = `${font}|${text}`; + let w = textCache.get(key); + if (w !== undefined) return w; + + if (textCache.size > TEXT_CACHE_LIMIT) textCache.clear(); + + const prevFont = ctx.font; + ctx.font = font; + w = ctx.measureText(text).width; + ctx.font = prevFont; + textCache.set(key, w); + return w; +} + +/** Exported for use by draw functions that need hex+alpha colors */ +export { ensureHex, hexWithAlpha }; diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts new file mode 100644 index 00000000..54c7129a --- /dev/null +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -0,0 +1,249 @@ +/** + * Canvas rendering constants for the agent graph visualization. + * Adapted from agent-flow's canvas-constants.ts (Apache 2.0). + * Stripped of unused features (tool cards, discoveries, cost overlays, bubbles). + */ + +// ─── Visibility threshold ─────────────────────────────────────────────────── + +export const MIN_VISIBLE_OPACITY = 0.05; + +// ─── Animation speed multipliers (× deltaTime) ───────────────────────────── + +export const ANIM_SPEED = { + agentFadeIn: 3, + agentScaleIn: 4, + agentFadeOut: 0.4, + agentScaleOut: 0.05, + edgeFadeIn: 4, + particleSpeed: 1.2, + maxDeltaTime: 0.1, + defaultDeltaTime: 0.016, + /** Task pill fade in/out */ + taskFadeIn: 3, + taskFadeOut: 0.6, +} as const; + +// ─── Camera / interaction ─────────────────────────────────────────────────── + +export const CAMERA = { + zoomStepDown: 0.92, + zoomStepUp: 1.08, + minZoom: 0.15, + maxZoom: 5, + velocityScale: 0.016, +} as const; + +// ─── Force simulation ─────────────────────────────────────────────────────── + +export const FORCE = { + chargeStrength: -800, + centerStrength: 0.03, + collideRadius: 100, + linkDistance: { + 'parent-child': 500, + ownership: 150, + blocking: 200, + related: 200, + message: 300, + }, + linkStrength: 0.4, + alphaDecay: 0.02, + velocityDecay: 0.4, +} as const; + +// ─── Node sizes ───────────────────────────────────────────────────────────── + +export const NODE = { + /** Lead agent radius */ + radiusLead: 32, + /** Team member radius */ + radiusMember: 24, + /** Process node radius */ + radiusProcess: 14, + /** Cross-team ghost node radius */ + radiusCrossTeam: 20, +} as const; + +// ─── Task pill dimensions ─────────────────────────────────────────────────── + +export const TASK_PILL = { + width: 160, + height: 36, + borderRadius: 6, + statusDotRadius: 4, + statusDotX: 12, + /** Font size for display ID */ + idFontSize: 9, + /** Font size for subject text */ + subjectFontSize: 7, + /** Max chars for subject before truncation */ + subjectMaxChars: 18, + /** X offset for text content */ + textOffsetX: 20, +} as const; + +// ─── Agent drawing constants ──────────────────────────────────────────────── + +export const AGENT_DRAW = { + glowPadding: 20, + outerRingOffset: 3, + shadowBlur: 15, + shadowOffsetX: 3, + shadowOffsetY: 5, + labelYOffset: 8, + labelWidthMultiplier: 3, + scanlineHalfH: 4, + waitingDashSpeed: 25, + orbitParticleOffset: 12, + orbitParticleSize: 1.5, + rippleInnerOffset: 5, + rippleMaxExpand: 45, + rippleMaxAlpha: 0.4, + waitingOrbitOffset: 14, + waitingOrbitParticleSize: 2, + waitingOrbitSpeed: 0.8, + waitingBreatheSpeed: 1.2, + waitingBreatheAmp: 0.08, + sparkScale: 0.45, + sparkViewBox: 256, + subIconScale: 0.45, +} as const; + +// ─── Context ring (lead node only) ───────────────────────────────────────── + +export const CONTEXT_RING = { + ringOffset: 8, + ringWidth: 4, + warningThreshold: 0.8, + criticalThreshold: 0.9, + percentLabelThreshold: 0.7, + glowPadding: 4, + glowLineWidth: 2, + glowBlur: 12, + percentYOffset: 10, +} as const; + +// ─── Edge/beam drawing ────────────────────────────────────────────────────── + +export const BEAM = { + curvature: 0.15, + cp1: 0.33, + cp2: 0.66, + segments: 16, + parentChild: { startW: 3, endW: 1 }, + ownership: { startW: 2, endW: 0.8 }, + blocking: { startW: 2, endW: 1.5 }, + related: { startW: 1, endW: 0.5 }, + message: { startW: 1.5, endW: 0.5 }, + glowExtra: { startW: 3, endW: 1, alpha: 0.08 }, + idleAlpha: 0.08, + activeAlpha: 0.3, + wobble: { amp: 3, freq: 10, timeFreq: 3, trailOffset: 0.15 }, +} as const; + +// ─── Animation constants ──────────────────────────────────────────────────── + +export const ANIM = { + inertiaDecay: 0.94, + inertiaThreshold: 0.5, + dragLerp: 0.25, + autoFitLerp: 0.06, + dragThresholdPx: 5, + viewportPadding: 120, + breathe: { + activeSpeed: 2, + activeAmp: 0.03, + idleSpeed: 0.7, + idleAmp: 0.015, + }, + scanline: { active: 40, normal: 15 }, + orbitSpeed: 1.5, + pulseSpeed: 4, +} as const; + +// ─── Visual effects ───────────────────────────────────────────────────────── + +export const FX = { + spawnDuration: 0.8, + completeDuration: 1.0, + shatterDuration: 0.8, + shatterCount: 12, + shatterSpeed: { min: 30, range: 60 }, + shatterSize: { min: 1, range: 2 }, + trailSegments: 8, +} as const; + +export const SPAWN_FX = { + ringStart: 10, + ringExpand: 60, + maxAlpha: 0.7, + flashThreshold: 0.3, + flashAlpha: 0.6, + flashBaseRadius: 20, + flashMinRadius: 5, + particleCount: 8, + particleSize: 1.5, +} as const; + +export const COMPLETE_FX = { + ringStart: 20, + ringExpand: 80, + maxAlpha: 0.6, + flashThreshold: 0.2, + flashAlpha: 0.8, + flashRadius: 30, + lineWidthMax: 3, + glowInner: 5, + glowOuter: 10, +} as const; + +// ─── Particle drawing ─────────────────────────────────────────────────────── + +export const PARTICLE_DRAW = { + glowRadius: 15, + coreHighlightScale: 0.4, + labelMinT: 0.2, + labelMaxT: 0.8, + labelFontSize: 8, + labelYOffset: -12, + /** Seconds a particle lives before fading */ + lifetime: 2.0, +} as const; + +// ─── Hit detection ────────────────────────────────────────────────────────── + +export const HIT_DETECTION = { + /** Extra padding around nodes for easier clicking */ + agentPadding: 8, + /** Task pill hit area padding */ + taskPadding: 4, +} as const; + +// ─── Background ───────────────────────────────────────────────────────────── + +export const BACKGROUND = { + /** Number of depth particles (stars) */ + starCount: 80, + /** Hex grid cell size */ + hexSize: 30, + /** Hex grid max alpha */ + hexAlpha: 0.08, + /** Hex grid pulse speed */ + hexPulseSpeed: 0.3, +} as const; + +// ─── Kanban zone layout ───────────────────────────────────────────────────── + +export const KANBAN_ZONE = { + /** Column width: pill (160) + gap (20) */ + columnWidth: 180, + /** Row height: pill (36) + gap (10) */ + rowHeight: 46, + /** Zone starts this far below member node center */ + offsetY: 70, + /** Column order: todo → wip → done → review → approved */ + columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, + /** Max tasks shown per column (overflow hidden) */ + maxVisibleRows: 6, +} as const; diff --git a/packages/agent-graph/src/constants/colors.ts b/packages/agent-graph/src/constants/colors.ts new file mode 100644 index 00000000..72537d31 --- /dev/null +++ b/packages/agent-graph/src/constants/colors.ts @@ -0,0 +1,169 @@ +/** + * Color palette for the space-themed graph visualization. + * Adapted from agent-flow's colors.ts (Apache 2.0). + * Uses our GraphNodeState instead of agent-flow's AgentState. + */ + +import type { GraphNodeState } from '../ports/types'; + +// ─── Holographic Color Palette ────────────────────────────────────────────── + +export const COLORS = { + // Background + void: '#050510', + hexGrid: '#0d0d1f', + + // Primary hologram + holoBase: '#66ccff', + holoBright: '#aaeeff', + holoHot: '#ffffff', + + // Node states + idle: '#66ccff', + active: '#66ccff', + thinking: '#66ccff', + tool_calling: '#ffbb44', + complete: '#66ffaa', + error: '#ff5566', + waiting: '#ffaa33', + terminated: '#888899', + + // Edge/Particle colors + dispatch: '#cc88ff', + return: '#66ffaa', + tool: '#ffbb44', + message: '#66ccff', + + // Task status colors + taskPending: '#6b7280', + taskInProgress: '#3b82f6', + taskCompleted: '#22c55e', + taskDeleted: '#ef4444', + + // Review state colors + reviewNone: 'transparent', + reviewPending: '#f59e0b', + reviewNeedsFix: '#ef4444', + reviewApproved: '#22c55e', + + // Edge type colors + edgeParentChild: '#66ccff', + edgeOwnership: '#66ccff', + edgeBlocking: '#ff5566', + edgeRelated: '#888899', + edgeMessage: '#cc88ff', + + // Particle kind colors + particleMessage: '#66ccff', + particleInboxMessage: '#66ccff', + particleTaskComment: '#ff9ad5', + particleTaskAssign: '#ffbb44', + particleReviewRequest: '#f59e0b', + particleReviewResponse: '#22c55e', + particleSpawn: '#cc88ff', + + // UI Chrome + nodeInterior: 'rgba(10, 15, 40, 0.5)', + textPrimary: '#aaeeff', + textDim: '#66ccff90', + textMuted: '#66ccff50', + + // Glass card (for popovers) + glassBg: 'rgba(10, 15, 30, 0.7)', + glassBorder: 'rgba(100, 200, 255, 0.15)', + glassHighlight: 'rgba(100, 200, 255, 0.08)', + + // Holo background/border opacities + holoBg05: 'rgba(100, 200, 255, 0.05)', + holoBg10: 'rgba(100, 200, 255, 0.1)', + holoBorder10: 'rgba(100, 200, 255, 0.1)', + holoBorder12: 'rgba(100, 200, 255, 0.12)', + + // Card backgrounds + cardBg: 'rgba(10, 15, 30, 0.6)', + cardBgSelected: 'rgba(100, 200, 255, 0.15)', + + // Controls + controlBg: 'rgba(8, 12, 24, 0.85)', + controlBorder: 'rgba(100, 200, 255, 0.1)', + controlActive: 'rgba(100, 200, 255, 0.15)', + controlInactive: 'rgba(100, 200, 255, 0.05)', +} as const; + +// ─── State Color Resolver ─────────────────────────────────────────────────── + +export function getStateColor(state: GraphNodeState): string { + switch (state) { + case 'idle': + return COLORS.idle; + case 'active': + return COLORS.active; + case 'thinking': + return COLORS.thinking; + case 'tool_calling': + return COLORS.tool_calling; + case 'complete': + return COLORS.complete; + case 'error': + return COLORS.error; + case 'waiting': + return COLORS.waiting; + case 'terminated': + return COLORS.terminated; + } +} + +// ─── Task Status Color Resolver ───────────────────────────────────────────── + +export function getTaskStatusColor( + status: 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined, +): string { + switch (status) { + case 'pending': + return COLORS.taskPending; + case 'in_progress': + return COLORS.taskInProgress; + case 'completed': + return COLORS.taskCompleted; + case 'deleted': + return COLORS.taskDeleted; + default: + return COLORS.taskPending; + } +} + +// ─── Review State Color Resolver ──────────────────────────────────────────── + +export function getReviewStateColor( + state: 'none' | 'review' | 'needsFix' | 'approved' | undefined, +): string { + switch (state) { + case 'review': + return COLORS.reviewPending; + case 'needsFix': + return COLORS.reviewNeedsFix; + case 'approved': + return COLORS.reviewApproved; + default: + return COLORS.reviewNone; + } +} + +// ─── Hex Color Alpha Utility ──────────────────────────────────────────────── + +// Pre-built LUT: index 0-255 → '00'-'ff' (avoids toString+padStart per call) +const ALPHA_HEX_LUT: string[] = []; +for (let i = 0; i < 256; i++) ALPHA_HEX_LUT.push(i.toString(16).padStart(2, '0')); + +/** Convert 0..1 alpha to 2-digit hex suffix (via LUT) */ +export function alphaHex(alpha: number): string { + return ALPHA_HEX_LUT[Math.round(Math.max(0, Math.min(1, alpha)) * 255)]; +} + +/** Safely combine a partial rgba base (e.g. "rgba(100, 200, 255,") with an alpha value */ +export function withAlpha(rgbaBase: string, alpha: number): string { + // Handles both "rgba(r,g,b," and "rgba(r, g, b," formats + const trimmed = rgbaBase.trimEnd(); + const separator = trimmed.endsWith(',') ? ' ' : ', '; + return `${trimmed}${separator}${alpha})`; +} diff --git a/packages/agent-graph/src/constants/index.ts b/packages/agent-graph/src/constants/index.ts new file mode 100644 index 00000000..9488d7c5 --- /dev/null +++ b/packages/agent-graph/src/constants/index.ts @@ -0,0 +1,27 @@ +export { + MIN_VISIBLE_OPACITY, + ANIM_SPEED, + CAMERA, + FORCE, + NODE, + TASK_PILL, + AGENT_DRAW, + CONTEXT_RING, + BEAM, + ANIM, + FX, + SPAWN_FX, + COMPLETE_FX, + PARTICLE_DRAW, + HIT_DETECTION, + BACKGROUND, +} from './canvas-constants'; + +export { + COLORS, + getStateColor, + getTaskStatusColor, + getReviewStateColor, + alphaHex, + withAlpha, +} from './colors'; diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts new file mode 100644 index 00000000..75b1ae89 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -0,0 +1,178 @@ +/** + * Camera hook — pan, zoom, auto-fit. + * Adapted from agent-flow's use-canvas-camera.ts (Apache 2.0). + * All state in refs — no React re-renders. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; + +export interface CameraTransform { + x: number; + y: number; + zoom: number; +} + +export interface UseGraphCameraResult { + transformRef: React.MutableRefObject; + screenToWorld: (sx: number, sy: number) => { x: number; y: number }; + worldToScreen: (wx: number, wy: number) => { x: number; y: number }; + handleWheel: (e: WheelEvent) => void; + handlePanStart: (sx: number, sy: number) => void; + handlePanMove: (sx: number, sy: number) => void; + handlePanEnd: () => void; + zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void; + zoomIn: () => void; + zoomOut: () => void; + updateInertia: () => void; +} + +export function useGraphCamera(): UseGraphCameraResult { + const transformRef = useRef({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject; + const panStartRef = useRef<{ x: number; y: number; camX: number; camY: number } | null>(null); + const velocityRef = useRef({ vx: 0, vy: 0 }); + + const screenToWorld = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + return { + x: (sx - t.x) / t.zoom, + y: (sy - t.y) / t.zoom, + }; + }, []); + + const worldToScreen = useCallback((wx: number, wy: number) => { + const t = transformRef.current; + return { + x: wx * t.zoom + t.x, + y: wy * t.zoom + t.y, + }; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + const t = transformRef.current; + + // Trackpad pinch (ctrlKey=true) sends small deltaY values — use them directly. + // Mouse wheel sends larger discrete deltaY — normalize to smaller steps. + let zoomDelta: number; + if (e.ctrlKey) { + // Pinch-to-zoom: deltaY is typically -2..+2, dampen it + zoomDelta = -e.deltaY * 0.008; + } else { + // Mouse wheel: deltaY is typically ±100-150, use discrete steps + zoomDelta = e.deltaY < 0 ? 0.08 : -0.08; + } + + const newZoom = Math.max(CAMERA.minZoom, Math.min(CAMERA.maxZoom, t.zoom * (1 + zoomDelta))); + + // Zoom toward cursor position + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect?.(); + const cx = rect ? e.clientX - rect.left : e.offsetX; + const cy = rect ? e.clientY - rect.top : e.offsetY; + + t.x = cx - (cx - t.x) * (newZoom / t.zoom); + t.y = cy - (cy - t.y) * (newZoom / t.zoom); + t.zoom = newZoom; + }, []); + + const lastPanPos = useRef({ x: 0, y: 0 }); + + const handlePanStart = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + panStartRef.current = { x: sx, y: sy, camX: t.x, camY: t.y }; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: 0, vy: 0 }; + }, []); + + const handlePanMove = useCallback((sx: number, sy: number) => { + const start = panStartRef.current; + if (!start) return; + const t = transformRef.current; + const dx = sx - start.x; + const dy = sy - start.y; + t.x = start.camX + dx; + t.y = start.camY + dy; + // Per-frame delta for inertia (not total drag distance) + const frameDx = sx - lastPanPos.current.x; + const frameDy = sy - lastPanPos.current.y; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: frameDx * CAMERA.velocityScale, vy: frameDy * CAMERA.velocityScale }; + }, []); + + const handlePanEnd = useCallback(() => { + panStartRef.current = null; + }, []); + + const updateInertia = useCallback(() => { + const v = velocityRef.current; + if (Math.abs(v.vx) < ANIM.inertiaThreshold && Math.abs(v.vy) < ANIM.inertiaThreshold) { + v.vx = 0; + v.vy = 0; + return; + } + const t = transformRef.current; + t.x += v.vx; + t.y += v.vy; + v.vx *= ANIM.inertiaDecay; + v.vy *= ANIM.inertiaDecay; + }, []); + + const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => { + if (nodes.length === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + const pad = n.kind === 'task' + ? TASK_PILL.width / 2 + : n.kind === 'lead' + ? NODE.radiusLead + : NODE.radiusMember; + minX = Math.min(minX, x - pad); + minY = Math.min(minY, y - pad); + maxX = Math.max(maxX, x + pad); + maxY = Math.max(maxY, y + pad); + } + + const padding = ANIM.viewportPadding; + const contentW = maxX - minX + padding * 2; + const contentH = maxY - minY + padding * 2; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const zoom = Math.max( + CAMERA.minZoom, + Math.min(CAMERA.maxZoom, Math.min(canvasW / contentW, canvasH / contentH)), + ); + + const t = transformRef.current; + t.zoom = zoom; + t.x = canvasW / 2 - centerX * zoom; + t.y = canvasH / 2 - centerY * zoom; + }, []); + + const zoomIn = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.min(CAMERA.maxZoom, t.zoom * 1.2); + }, []); + + const zoomOut = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); + }, []); + + return { + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts new file mode 100644 index 00000000..81cdf5be --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -0,0 +1,93 @@ +/** + * Interaction hook — click, hover, drag on canvas. + * Delegates hit testing to strategy pattern. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { ANIM } from '../constants/canvas-constants'; +import { findNodeAt } from '../canvas/hit-detection'; + +export interface UseGraphInteractionResult { + hoveredNodeId: React.RefObject; + dragNodeId: React.RefObject; + isDragging: React.RefObject; + handleMouseDown: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseMove: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseUp: () => string | null; + handleDoubleClick: (wx: number, wy: number, nodes: GraphNode[]) => string | null; +} + +export function useGraphInteraction( + onDragNode?: (nodeId: string, x: number, y: number) => void, +): UseGraphInteractionResult { + const hoveredNodeId = useRef(null); + const dragNodeId = useRef(null); + const isDragging = useRef(false); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + const clickedNodeId = useRef(null); + + const handleMouseDown = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + mouseDownPos.current = { x: wx, y: wy }; + const hit = findNodeAt(wx, wy, nodes); + clickedNodeId.current = hit; + + if (hit) { + // Only allow drag on member/lead nodes, not tasks or processes + const hitNode = nodes.find((n) => n.id === hit); + if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) { + dragNodeId.current = hit; + } + } + }, []); + + const handleMouseMove = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + // Check drag threshold + if (mouseDownPos.current && dragNodeId.current) { + const dx = wx - mouseDownPos.current.x; + const dy = wy - mouseDownPos.current.y; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + isDragging.current = true; + } + } + + // Drag node + if (isDragging.current && dragNodeId.current) { + onDragNode?.(dragNodeId.current, wx, wy); + return; + } + + // Hover detection + hoveredNodeId.current = findNodeAt(wx, wy, nodes); + }, [onDragNode]); + + const handleMouseUp = useCallback((): string | null => { + const wasDragging = isDragging.current; + const nodeId = clickedNodeId.current; + + isDragging.current = false; + dragNodeId.current = null; + mouseDownPos.current = null; + clickedNodeId.current = null; + + // If not dragging, this was a click + if (!wasDragging && nodeId) { + return nodeId; + } + return null; + }, []); + + const handleDoubleClick = useCallback((wx: number, wy: number, nodes: GraphNode[]): string | null => { + return findNodeAt(wx, wy, nodes); + }, []); + + return { + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts new file mode 100644 index 00000000..94ecb8ba --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -0,0 +1,306 @@ +/** + * Graph simulation hook using d3-force for MEMBER/LEAD nodes only. + * Task nodes are positioned by KanbanLayoutEngine (deterministic grid). + * + * CRITICAL: Animation state in useRef, NOT useState — no React re-renders at 60fps. + * This hook does NOT run its own RAF loop — the parent (GraphView) calls tick(). + */ + +import { useRef, useEffect, useCallback } from 'react'; +import { + forceSimulation, + forceCenter, + forceManyBody, + forceCollide, + forceLink, + type Simulation, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; +import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; +import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants'; +import { getNodeStrategy } from '../strategies'; +import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; +import { getStateColor } from '../constants/colors'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; + +// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── + +interface ForceNode extends SimulationNodeDatum { + id: string; + kind: GraphNodeKind; +} + +interface ForceLink extends SimulationLinkDatum { + id: string; + edgeType: string; +} + +// ─── Simulation State (in ref, not useState) ──────────────────────────────── + +export interface SimulationState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; +} + +export interface UseGraphSimulationResult { + stateRef: React.MutableRefObject; + updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; + /** Tick one simulation frame — called from parent's RAF loop */ + tick: (dt: number) => void; +} + +// ─── Deterministic hash for stable initial positions ───────────────────────── + +/** Returns a value in [-0.5, 0.5] deterministically from string + seed */ +function deterministicPosition(id: string, seed: number): number { + let hash = seed * 2654435761; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; + } + return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export function useGraphSimulation(): UseGraphSimulationResult { + const stateRef = useRef({ + nodes: [], + edges: [], + particles: [], + effects: [], + time: 0, + }); + + const simRef = useRef | null>(null); + + // Initialize d3-force simulation + const initSimulation = useCallback(() => { + if (simRef.current) simRef.current.stop(); + + const sim = forceSimulation([]) + .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) + .force('charge', forceManyBody().strength((d) => { + return getNodeStrategy(d.kind).getChargeStrength(); + })) + .force('collide', forceCollide().radius((d) => { + return getNodeStrategy(d.kind).getCollisionRadius(); + })) + .force('link', forceLink([]).id((d) => d.id).distance((d) => { + return FORCE.linkDistance[d.edgeType as keyof typeof FORCE.linkDistance] ?? 200; + }).strength(FORCE.linkStrength)) + .alphaDecay(FORCE.alphaDecay) + .velocityDecay(FORCE.velocityDecay) + .stop(); // We tick manually + + simRef.current = sim; + return sim; + }, []); + + // Track node set identity to avoid re-running simulation when data reference changes but content is same + const lastNodeIdsHash = useRef(''); + + // Sync graph data to d3-force — ONLY when node set actually changes + const syncSimulation = useCallback((nodes: GraphNode[], edges: GraphEdge[]) => { + // Hash includes IDs + mutable fields (status, owner, review) to detect real changes + const hash = nodes.map((n) => `${n.id}:${n.state}:${n.ownerId ?? ''}:${n.taskStatus ?? ''}:${n.reviewState ?? ''}`).sort().join(','); + if (hash === lastNodeIdsHash.current) return; // same nodes — skip re-simulation + lastNodeIdsHash.current = hash; + + let sim = simRef.current; + if (!sim) sim = initSimulation(); + + // Tasks excluded from d3-force — positioned by KanbanLayoutEngine + const forceNodes: ForceNode[] = nodes + .filter((n) => n.kind !== 'task') + .map((n) => ({ + id: n.id, + kind: n.kind, + // Deterministic initial positions from node ID hash — same layout every time + x: n.x ?? deterministicPosition(n.id, 0) * 500, + y: n.y ?? deterministicPosition(n.id, 1) * 500, + vx: n.vx ?? 0, + vy: n.vy ?? 0, + fx: n.fx, + fy: n.fy, + })); + + // Links only between non-task nodes (parent-child: lead↔member) + const forceNodeIds = new Set(forceNodes.map((n) => n.id)); + const forceLinks: ForceLink[] = edges + .filter((e) => forceNodeIds.has(e.source) && forceNodeIds.has(e.target)) + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + edgeType: e.type, + })); + + sim.nodes(forceNodes); + (sim.force('link') as ReturnType)?.links(forceLinks); + sim.alpha(1); + + // Run simulation to near-completion so nodes are settled on first render + for (let i = 0; i < 120; i++) sim.tick(); + sim.alpha(0); // fully settled — no more movement until new data + + // Copy settled positions BACK to GraphNode objects + const simNodeMap = new Map(); + for (const sn of sim.nodes()) simNodeMap.set(sn.id, sn); + for (const node of nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + + // Position tasks in kanban zones relative to their owners + KanbanLayoutEngine.layout(nodes); + }, [initSimulation]); + + // Track previous node IDs and states for effect spawning + const prevNodeIdsRef = useRef(new Set()); + const prevNodeStatesRef = useRef(new Map()); + // All node IDs ever seen — never shrinks. Prevents spawn effects replaying + // when nodes reappear after being filtered out (e.g. Tasks toggle OFF→ON). + const allKnownNodeIdsRef = useRef(new Set()); + + // Update data from adapter + const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { + const state = stateRef.current; + const prevStates = prevNodeStatesRef.current; + + // Preserve positions from previous frame + const prevPositions = new Map(); + for (const n of state.nodes) { + if (n.x != null && n.y != null) { + prevPositions.set(n.id, { x: n.x, y: n.y, vx: n.vx ?? 0, vy: n.vy ?? 0 }); + } + } + + for (const n of nodes) { + const prev = prevPositions.get(n.id); + if (prev && n.x == null) { + n.x = prev.x; + n.y = prev.y; + n.vx = prev.vx; + n.vy = prev.vy; + } + } + + // Detect state transitions → spawn visual effects + const allKnown = allKnownNodeIdsRef.current; + for (const node of nodes) { + // New node appeared → spawn effect (only if truly new, never seen before). + // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. + if (!allKnown.has(node.id) && node.x != null && node.y != null) { + const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined; + state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR)); + } + + // Task completed → shatter effect + const prevState = prevStates.get(node.id); + if (prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null) { + state.effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + } + + // Update tracking refs — allKnown only grows, never shrinks + for (const n of nodes) allKnown.add(n.id); + prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); + prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); + + state.nodes = nodes; + state.edges = edges; + state.particles = mergeParticles(state.particles, particles); + + syncSimulation(nodes, edges); + }, [syncSimulation]); + + // Tick one frame (called by parent's RAF loop) + const tick = useCallback((dt: number) => { + tickFrame(stateRef.current, simRef.current, dt); + }, []); + + // Cleanup + useEffect(() => { + return () => { + simRef.current?.stop(); + }; + }, []); + + return { stateRef, updateData, tick }; +} + +function mergeParticles( + existing: GraphParticle[], + incoming: GraphParticle[], +): GraphParticle[] { + if (existing.length === 0) return incoming; + if (incoming.length === 0) return existing; + + const merged = existing.slice(); + const seen = new Set(existing.map((particle) => particle.id)); + for (const particle of incoming) { + if (seen.has(particle.id)) continue; + merged.push(particle); + seen.add(particle.id); + } + return merged; +} + +// ─── Frame Tick (pure function) ───────────────────────────────────────────── + +function tickFrame( + state: SimulationState, + sim: Simulation | null, + dt: number, +): void { + state.time += dt; + + // Tick d3-force (only when simulation is still active) + if (sim && sim.alpha() > 0.001) { + sim.tick(1); + + const simNodes = sim.nodes(); + const simNodeMap = new Map(); + for (const sn of simNodes) simNodeMap.set(sn.id, sn); + + for (const node of state.nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + } + + // Re-layout tasks in kanban zones — always run to handle new/moved tasks + KanbanLayoutEngine.layout(state.nodes); + + // Update particle progress — in-place removal (no new array allocation) + let pw = 0; + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.progress += dt * ANIM_SPEED.particleSpeed * 0.5; + if (p.progress < 1) state.particles[pw++] = p; + } + state.particles.length = pw; + + // Update effects — in-place removal + let ew = 0; + for (let i = 0; i < state.effects.length; i++) { + const fx = state.effects[i]; + fx.age += dt; + if (fx.age < fx.duration) state.effects[ew++] = fx; + } + state.effects.length = ew; +} diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts new file mode 100644 index 00000000..26a1f01c --- /dev/null +++ b/packages/agent-graph/src/index.ts @@ -0,0 +1,28 @@ +/** + * @claude-teams/agent-graph + * + * Force-directed graph visualization for agent teams. + * Isolated package — depends only on React (peer) and d3-force. + * Uses Port/Adapter pattern: host project provides data through port interfaces. + */ + +// ─── Components ────────────────────────────────────────────────────────────── +export { GraphView } from './ui/GraphView'; +export type { GraphViewProps } from './ui/GraphView'; + +// ─── Port Interfaces (for adapters in host project) ───────────────────────── +export type { GraphDataPort } from './ports/GraphDataPort'; +export type { GraphEventPort } from './ports/GraphEventPort'; +export type { GraphConfigPort } from './ports/GraphConfigPort'; + +// ─── Port Types ────────────────────────────────────────────────────────────── +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './ports/types'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts new file mode 100644 index 00000000..e8bb24e4 --- /dev/null +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -0,0 +1,238 @@ +/** + * KanbanLayoutEngine — positions task nodes in kanban columns relative to their owner. + * + * Each member/lead gets a zone below them with columns for non-empty statuses only. + * Empty columns are skipped — no wasted space. Each column has a header label. + * + * Class with ES #private methods, single source of truth from KANBAN_ZONE constants. + */ + +import type { GraphNode } from '../ports/types'; +import { KANBAN_ZONE } from '../constants/canvas-constants'; +import { COLORS } from '../constants/colors'; + +/** Column header info for rendering */ +export interface KanbanColumnHeader { + label: string; + x: number; + y: number; + color: string; + /** Number of hidden overflow tasks in this column */ + overflowCount: number; + /** Y position for the overflow badge */ + overflowY: number; +} + +/** Zone info per owner for rendering headers */ +export interface KanbanZoneInfo { + ownerId: string; + ownerX: number; + ownerY: number; + headers: KanbanColumnHeader[]; +} + +// Column display config — colors from single source of truth (COLORS) +const COLUMN_LABELS: Record = { + todo: { label: 'Todo', color: COLORS.taskPending }, + wip: { label: 'In Progress', color: COLORS.taskInProgress }, + done: { label: 'Done', color: COLORS.taskCompleted }, + review: { label: 'Review', color: COLORS.reviewPending }, + approved: { label: 'Approved', color: COLORS.reviewApproved }, +}; + +export class KanbanLayoutEngine { + // Reusable collections (cleared each call, never GC'd) + static readonly #nodeMap = new Map(); + static readonly #tasksByOwner = new Map(); + static readonly #unassigned: GraphNode[] = []; + static readonly #colTasks = new Map(); + + /** Zone info for rendering column headers — updated each layout() call */ + static zones: KanbanZoneInfo[] = []; + + /** + * Position all task nodes in kanban columns relative to their owner. + * Call AFTER d3-force settles member positions, BEFORE drawing. + */ + static layout(nodes: GraphNode[]): void { + const nodeMap = this.#nodeMap; + nodeMap.clear(); + for (const n of nodes) nodeMap.set(n.id, n); + + const tasksByOwner = this.#tasksByOwner; + tasksByOwner.clear(); + const unassigned = this.#unassigned; + unassigned.length = 0; + + for (const n of nodes) { + if (n.kind !== 'task') continue; + if (n.ownerId) { + let group = tasksByOwner.get(n.ownerId); + if (!group) { + group = []; + tasksByOwner.set(n.ownerId, group); + } + group.push(n); + } else { + unassigned.push(n); + } + } + + // Reset zones + this.zones = []; + + for (const [ownerId, tasks] of tasksByOwner) { + const owner = nodeMap.get(ownerId); + if (!owner || owner.x == null || owner.y == null) continue; + const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId); + if (zoneInfo) this.zones.push(zoneInfo); + } + + KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { + const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; + const headerHeight = 20; // space for column header label + const baseY = ownerY + offsetY; + + // Classify tasks into columns + const colTasks = KanbanLayoutEngine.#colTasks; + colTasks.clear(); + for (const col of columns) colTasks.set(col, []); + + for (const task of tasks) { + const col = KanbanLayoutEngine.#resolveColumn(task); + colTasks.get(col)?.push(task); + } + + // Collect only NON-EMPTY columns (skip empty — no wasted space) + const activeColumns: { name: string; tasks: GraphNode[] }[] = []; + for (const colName of columns) { + const nodes = colTasks.get(colName) ?? []; + if (nodes.length > 0) { + activeColumns.push({ name: colName, tasks: nodes }); + } + } + + if (activeColumns.length === 0) return null; + + // Center active columns under owner + const totalWidth = activeColumns.length * columnWidth; + const baseX = ownerX - totalWidth / 2; + + // Build headers + position tasks + const headers: KanbanColumnHeader[] = []; + + for (const [colIdx, col] of activeColumns.entries()) { + const colX = baseX + colIdx * columnWidth; + const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; + const overflow = Math.max(0, col.tasks.length - maxVisibleRows); + const visibleCount = Math.min(col.tasks.length, maxVisibleRows); + + // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) + headers.push({ + label: config.label, + x: colX, // pill center = task.x = colX + y: baseY, + color: config.color, + overflowCount: overflow, + overflowY: baseY + headerHeight + visibleCount * rowHeight, + }); + + // Position tasks below header + for (const [rowIdx, task] of col.tasks.entries()) { + if (rowIdx >= maxVisibleRows) { + task.x = -99999; + task.y = -99999; + task.fx = task.x; + task.fy = task.y; + continue; + } + const targetX = colX; + const targetY = baseY + headerHeight + rowIdx * rowHeight; + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } + + return { ownerId, ownerX, ownerY, headers }; + } + + static #resolveColumn(task: GraphNode): string { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + switch (task.taskStatus) { + case 'in_progress': + return 'wip'; + case 'completed': + return 'done'; + default: + return 'todo'; + } + } + + static #layoutUnassigned(tasks: GraphNode[], allNodes: GraphNode[]): void { + if (tasks.length === 0) return; + + const { columnWidth, rowHeight } = KANBAN_ZONE; + + // Find the lowest Y of ALL positioned nodes (members + their owned tasks) + let sumX = 0; + let maxY = -Infinity; + let memberCount = 0; + for (const n of allNodes) { + if (n.x == null || n.y == null) continue; + // Skip unassigned tasks themselves (they have no ownerId) + if (n.kind === 'task' && !n.ownerId) continue; + if (n.y > maxY) maxY = n.y; + if (n.kind !== 'task') { + sumX += n.x; + memberCount++; + } + } + + const centerX = memberCount > 0 ? sumX / memberCount : 0; + // Place unassigned tasks well below the lowest element + const baseY = (maxY > -Infinity ? maxY : 0) + 150; + const cols = Math.min(tasks.length, 4); + const totalWidth = cols * columnWidth; + const baseX = centerX - totalWidth / 2; + + // Add zone header for unassigned section + if (tasks.length > 0) { + this.zones.push({ + ownerId: '__unassigned__', + ownerX: centerX, + ownerY: baseY - 70, + headers: [{ + label: 'Unassigned', + x: centerX, + y: baseY - 10, + color: COLORS.taskPending, + overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows), + overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, + }], + }); + } + + for (const [idx, task] of tasks.entries()) { + const col = idx % cols; + const row = Math.floor(idx / cols); + const targetX = baseX + col * columnWidth; + const targetY = baseY + row * rowHeight; + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } +} diff --git a/packages/agent-graph/src/ports/GraphConfigPort.ts b/packages/agent-graph/src/ports/GraphConfigPort.ts new file mode 100644 index 00000000..6065bfe2 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphConfigPort.ts @@ -0,0 +1,55 @@ +import type { GraphNodeState } from './types'; + +/** + * Configuration port — visual theme, filters, animation settings. + * All fields optional — package provides sensible defaults. + */ +export interface GraphConfigPort { + // ─── Theme ───────────────────────────────────────────────────────────── + /** Background color (default: space dark #0a0f1a) */ + backgroundColor?: string; + /** Whether to show hex grid on background */ + showHexGrid?: boolean; + /** Whether to show depth star field */ + showStarField?: boolean; + /** Bloom post-processing intensity (0 = off, 1 = default) */ + bloomIntensity?: number; + + // ─── Node Colors (overrides per state) ───────────────────────────────── + nodeStateColors?: Partial>; + /** Task status colors */ + taskStatusColors?: { + pending?: string; + in_progress?: string; + completed?: string; + deleted?: string; + }; + /** Review state colors */ + reviewStateColors?: { + review?: string; + needsFix?: string; + approved?: string; + }; + + // ─── Filters (show/hide node kinds) ──────────────────────────────────── + showTasks?: boolean; + showProcesses?: boolean; + showCompletedTasks?: boolean; + showEdgeLabels?: boolean; + + // ─── Animation ───────────────────────────────────────────────────────── + /** Animation enabled (default: true) */ + animationEnabled?: boolean; + /** Particle speed multiplier (default: 1) */ + particleSpeed?: number; + /** Breathing animation speed (default: 1) */ + breathingSpeed?: number; + + // ─── Force Layout ────────────────────────────────────────────────────── + /** Charge strength (repulsion, default: -800) */ + chargeStrength?: number; + /** Center attraction strength (default: 0.03) */ + centerStrength?: number; + /** Task orbit radius around owner (default: 150) */ + taskOrbitRadius?: number; +} diff --git a/packages/agent-graph/src/ports/GraphDataPort.ts b/packages/agent-graph/src/ports/GraphDataPort.ts new file mode 100644 index 00000000..ec4c5ce0 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphDataPort.ts @@ -0,0 +1,20 @@ +import type { GraphNode, GraphEdge, GraphParticle } from './types'; + +/** + * Data provider port — supplies graph state to the visualization. + * Host project implements this via an adapter (e.g., useTeamGraphAdapter). + */ +export interface GraphDataPort { + /** All nodes to render (members, tasks, processes, lead) */ + nodes: GraphNode[]; + /** All edges (ownership, blocking, related, message, parent-child) */ + edges: GraphEdge[]; + /** Active particles (messages in flight, spawn effects) */ + particles: GraphParticle[]; + /** Team name for display */ + teamName: string; + /** Team brand color */ + teamColor?: string; + /** Whether the team lead process is alive */ + isAlive?: boolean; +} diff --git a/packages/agent-graph/src/ports/GraphEventPort.ts b/packages/agent-graph/src/ports/GraphEventPort.ts new file mode 100644 index 00000000..8ed94ca6 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphEventPort.ts @@ -0,0 +1,22 @@ +import type { GraphDomainRef, GraphEdge } from './types'; + +/** + * Event callback port — graph fires these when user interacts with nodes/edges. + * Host project provides handlers to navigate to domain-specific views. + */ +export interface GraphEventPort { + /** Single click on a node — show popover with details */ + onNodeClick?: (ref: GraphDomainRef) => void; + /** Double click on a node — open full detail dialog */ + onNodeDoubleClick?: (ref: GraphDomainRef) => void; + /** Click on an edge */ + onEdgeClick?: (edge: GraphEdge) => void; + /** Click on empty canvas background */ + onBackgroundClick?: () => void; + /** "Send Message" action from node popover */ + onSendMessage?: (memberName: string, teamName: string) => void; + /** "Open Task Detail" action from task popover */ + onOpenTaskDetail?: (taskId: string, teamName: string) => void; + /** "Open Member Profile" action from member popover */ + onOpenMemberProfile?: (memberName: string, teamName: string) => void; +} diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts new file mode 100644 index 00000000..532bc497 --- /dev/null +++ b/packages/agent-graph/src/ports/index.ts @@ -0,0 +1,13 @@ +export type { GraphDataPort } from './GraphDataPort'; +export type { GraphEventPort } from './GraphEventPort'; +export type { GraphConfigPort } from './GraphConfigPort'; +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts new file mode 100644 index 00000000..8bcc43f0 --- /dev/null +++ b/packages/agent-graph/src/ports/types.ts @@ -0,0 +1,165 @@ +/** + * Core types for graph visualization. + * Framework-agnostic — no dependencies on TeamData, Zustand, Electron, or agent-flow internals. + */ + +// ─── Node Kinds ────────────────────────────────────────────────────────────── + +export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process' | 'crossteam'; + +export type GraphNodeState = + | 'idle' + | 'active' + | 'thinking' + | 'tool_calling' + | 'waiting' + | 'complete' + | 'error' + | 'terminated'; + +// ─── Edge & Particle Types ─────────────────────────────────────────────────── + +export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message'; + +export type GraphParticleKind = + | 'inbox_message' + | 'task_comment' + | 'task_assign' + | 'review_request' + | 'review_response' + | 'spawn'; + +// ─── Graph Node ────────────────────────────────────────────────────────────── + +export interface GraphNode { + /** Unique node identifier (e.g., "member:alice", "task:abc123") */ + id: string; + kind: GraphNodeKind; + label: string; + state: GraphNodeState; + + /** Node color override (e.g., member.color hex value) */ + color?: string; + + // ─── Member/Lead-specific ────────────────────────────────────────────── + /** Agent role description */ + role?: string; + /** Avatar image URL (e.g., robohash) */ + avatarUrl?: string; + /** Spawn lifecycle status */ + spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + /** Context window usage ratio (0..1), available for lead only */ + contextUsage?: number; + /** Current task ID this member is working on */ + currentTaskId?: string | null; + /** Current task subject (for display in popover) */ + currentTaskSubject?: string; + /** Agent is awaiting tool approval from the user */ + pendingApproval?: boolean; + /** Currently running or just-finished tool activity shown near the node */ + activeTool?: { + name: string; + preview?: string; + state: 'running' | 'complete' | 'error'; + startedAt: string; + finishedAt?: string; + resultPreview?: string; + source: 'runtime' | 'member_log' | 'inbox'; + }; + /** Recent completed tool activity for popovers and secondary UI */ + recentTools?: Array<{ + name: string; + preview?: string; + state: 'complete' | 'error'; + startedAt: string; + finishedAt: string; + resultPreview?: string; + source: 'runtime' | 'member_log' | 'inbox'; + }>; + + // ─── Task-specific ───────────────────────────────────────────────────── + /** Short display ID (e.g., "#3") */ + displayId?: string; + /** Task subject / description */ + sublabel?: string; + /** Owner member node ID — tasks orbit around this node */ + ownerId?: string | null; + /** Task status for pill coloring */ + taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + /** Review state overlay */ + reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; + /** Requires clarification indicator */ + needsClarification?: 'lead' | 'user' | null; + /** Task is blocked by other tasks */ + isBlocked?: boolean; + /** Display IDs of tasks blocking this one */ + blockedByDisplayIds?: string[]; + /** Display IDs of tasks this one blocks */ + blocksDisplayIds?: string[]; + /** Total comment count on this task */ + totalCommentCount?: number; + /** Unread comment count on this task */ + unreadCommentCount?: number; + + // ─── Process-specific ────────────────────────────────────────────────── + /** Clickable URL for process */ + processUrl?: string; + /** Who registered the process */ + processRegisteredBy?: string; + /** Command used to start the process */ + processCommand?: string; + /** When the process was registered (ISO) */ + processRegisteredAt?: string; + + // ─── Force simulation (managed by the package internally) ────────────── + x?: number; + y?: number; + vx?: number; + vy?: number; + /** Pinned position (user-dragged) */ + fx?: number | null; + fy?: number | null; + + // ─── Domain reference (opaque, for navigation back to host app) ──────── + domainRef: GraphDomainRef; +} + +// ─── Graph Edge ────────────────────────────────────────────────────────────── + +export interface GraphEdge { + id: string; + source: string; + target: string; + type: GraphEdgeType; + /** Label shown on edge (e.g., message summary) */ + label?: string; + /** Edge color override */ + color?: string; +} + +// ─── Graph Particle ────────────────────────────────────────────────────────── + +export interface GraphParticle { + id: string; + /** Edge ID this particle travels along */ + edgeId: string; + /** Progress along edge (0..1) */ + progress: number; + kind: GraphParticleKind; + color: string; + /** Size multiplier (1 = default) */ + size?: number; + /** Short label near particle */ + label?: string; + /** If true, particle travels from target → source (reverse direction) */ + reverse?: boolean; +} + +// ─── Domain Reference (opaque back-pointer) ────────────────────────────────── + +export type GraphDomainRef = + | { kind: 'lead'; teamName: string; memberName: string } + | { kind: 'member'; teamName: string; memberName: string } + | { kind: 'task'; teamName: string; taskId: string } + | { kind: 'process'; teamName: string; processId: string } + | { kind: 'crossteam'; teamName: string; externalTeamName: string }; diff --git a/packages/agent-graph/src/strategies/index.ts b/packages/agent-graph/src/strategies/index.ts new file mode 100644 index 00000000..ea7d4e9c --- /dev/null +++ b/packages/agent-graph/src/strategies/index.ts @@ -0,0 +1,28 @@ +/** + * Strategy registry — maps GraphNodeKind to its render strategy. + * Open-Closed: add new node kinds by adding new strategies to the registry. + */ + +import type { GraphNodeKind } from '../ports/types'; +import type { NodeRenderStrategy } from './types'; +import { LeadStrategy, MemberStrategy } from './memberStrategy'; +import { TaskStrategy } from './taskStrategy'; +import { ProcessStrategy } from './processStrategy'; + +const STRATEGIES: Record = { + lead: new LeadStrategy(), + member: new MemberStrategy(), + task: new TaskStrategy(), + process: new ProcessStrategy(), + crossteam: new ProcessStrategy(), // Reuse process strategy (similar small node) +}; + +export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy { + return STRATEGIES[kind]; +} + +export function getAllStrategies(): NodeRenderStrategy[] { + return Object.values(STRATEGIES); +} + +export type { NodeRenderStrategy, NodeRenderState } from './types'; diff --git a/packages/agent-graph/src/strategies/memberStrategy.ts b/packages/agent-graph/src/strategies/memberStrategy.ts new file mode 100644 index 00000000..789b47a1 --- /dev/null +++ b/packages/agent-graph/src/strategies/memberStrategy.ts @@ -0,0 +1,72 @@ +/** + * Render strategy for member and lead nodes. + * Uses the holographic hexagonal rendering from draw-agents.ts. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawAgents } from '../canvas/draw-agents'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class MemberStrategy implements NodeRenderStrategy { + readonly kind = 'member' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + // drawAgents handles both member and lead — we delegate to it + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusMember + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusMember + 20; + } + + getChargeStrength(): number { + return -600; + } +} + +export class LeadStrategy implements NodeRenderStrategy { + readonly kind = 'lead' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusLead + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusLead + 30; + } + + getChargeStrength(): number { + return -1200; + } +} diff --git a/packages/agent-graph/src/strategies/processStrategy.ts b/packages/agent-graph/src/strategies/processStrategy.ts new file mode 100644 index 00000000..f96b78f1 --- /dev/null +++ b/packages/agent-graph/src/strategies/processStrategy.ts @@ -0,0 +1,39 @@ +/** + * Render strategy for process nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawProcesses } from '../canvas/draw-processes'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class ProcessStrategy implements NodeRenderStrategy { + readonly kind = 'process' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawProcesses( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusProcess + 10; + } + + getChargeStrength(): number { + return -200; + } +} diff --git a/packages/agent-graph/src/strategies/taskStrategy.ts b/packages/agent-graph/src/strategies/taskStrategy.ts new file mode 100644 index 00000000..96a9a92b --- /dev/null +++ b/packages/agent-graph/src/strategies/taskStrategy.ts @@ -0,0 +1,38 @@ +/** + * Render strategy for task pill nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawTasks } from '../canvas/draw-tasks'; +import { TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +export class TaskStrategy implements NodeRenderStrategy { + readonly kind = 'task' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawTasks( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + return wx >= x - halfW && wx <= x + halfW && wy >= y - halfH && wy <= y + halfH; + } + + getCollisionRadius(): number { + return Math.max(TASK_PILL.width, TASK_PILL.height) / 2 + 10; + } + + getChargeStrength(): number { + return -300; + } +} diff --git a/packages/agent-graph/src/strategies/types.ts b/packages/agent-graph/src/strategies/types.ts new file mode 100644 index 00000000..b683d09a --- /dev/null +++ b/packages/agent-graph/src/strategies/types.ts @@ -0,0 +1,48 @@ +/** + * Strategy interfaces for per-kind node rendering, hit testing, and layout. + * Open-Closed principle: new node kinds add new strategies, no changes to GraphCanvas. + */ + +import type { GraphNode, GraphNodeKind } from '../ports/types'; + +/** + * Rendering state passed to strategy draw methods (animation context). + */ +export interface NodeRenderState { + isSelected: boolean; + isHovered: boolean; + time: number; + cameraZoom: number; +} + +/** + * Strategy for rendering a specific node kind. + * Liskov: all strategies are interchangeable via the registry. + */ +export interface NodeRenderStrategy { + readonly kind: GraphNodeKind; + + /** + * Draw the node on the canvas. + */ + draw( + ctx: CanvasRenderingContext2D, + node: GraphNode, + state: NodeRenderState, + ): void; + + /** + * Test whether the world-space point (wx, wy) is inside this node. + */ + hitTest(node: GraphNode, wx: number, wy: number): boolean; + + /** + * Get collision radius for d3-force collide simulation. + */ + getCollisionRadius(): number; + + /** + * Get charge strength for d3-force many-body simulation. + */ + getChargeStrength(): number; +} diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx new file mode 100644 index 00000000..3548c49b --- /dev/null +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -0,0 +1,297 @@ +/** + * GraphCanvas — Canvas 2D rendering component with imperative RAF draw loop. + * + * ARCHITECTURE: The canvas draws imperatively via drawRef, NOT via React re-renders. + * GraphView calls `drawRef.current()` from the unified RAF loop. + * React only manages: mount/unmount, resize, mouse events. + */ + +import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; +import { drawEdges } from '../canvas/draw-edges'; +import { drawParticles } from '../canvas/draw-particles'; +import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; +import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; +import { drawProcesses } from '../canvas/draw-processes'; +import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { BloomRenderer } from '../canvas/bloom-renderer'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; +import type { CameraTransform } from '../hooks/useGraphCamera'; + +// ─── Draw State (passed by ref, not by props — no React re-renders) ───────── + +export interface GraphDrawState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; + camera: CameraTransform; + selectedNodeId: string | null; + hoveredNodeId: string | null; +} + +export interface GraphCanvasHandle { + /** Call this from RAF to draw one frame */ + draw: (state: GraphDrawState) => void; + /** Get the canvas element for coordinate transforms */ + getCanvas: () => HTMLCanvasElement | null; +} + +export interface GraphCanvasProps { + showHexGrid?: boolean; + showStarField?: boolean; + bloomIntensity?: number; + onWheel?: (e: WheelEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; +} + +export const GraphCanvas = forwardRef(function GraphCanvas( + { + showHexGrid = true, + showStarField = true, + bloomIntensity = 0.6, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + onDoubleClick, + onContextMenu, + className, + }, + ref, +) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const bloomRef = useRef(new BloomRenderer(bloomIntensity)); + const starsRef = useRef([]); + const sizeRef = useRef({ w: 0, h: 0 }); + + // Performance tracking + const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] }); + // Rate-limited error logging (prevent console flood at 60fps) + const lastDrawErrorRef = useRef(0); + + // Update bloom intensity without recreating + useEffect(() => { + bloomRef.current.setIntensity(bloomIntensity); + }, [bloomIntensity]); + + // Handle resize + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + const dpr = window.devicePixelRatio || 1; + const canvas = canvasRef.current; + if (!canvas) continue; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + sizeRef.current = { w: width, h: height }; + bloomRef.current.resize(width * dpr, height * dpr); + starsRef.current = createDepthParticles(width, height); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + // Persistent per-frame collections (reused, never GC'd) + const nodeMapCache = useRef(new Map()); + const edgeMapCache = useRef(new Map()); + const visibleNodesCache = useRef([]); + const visibleEdgesCache = useRef([]); + const visibleNodeIdsCache = useRef(new Set()); + const activeParticleEdgesCache = useRef(new Set()); + + // Imperative draw function — called from RAF, NOT from React render + useImperativeHandle(ref, () => ({ + draw: (state: GraphDrawState) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const frameStart = performance.now(); + + const dpr = window.devicePixelRatio || 1; + const { w, h } = sizeRef.current; + if (w === 0 || h === 0) return; + + try { + + const cam = state.camera; + const zoom = cam.zoom; + + // ─── Frustum culling: compute visible world-space bounds ────────── + const viewLeft = -cam.x / zoom; + const viewTop = -cam.y / zoom; + const viewRight = (w - cam.x) / zoom; + const viewBottom = (h - cam.y) / zoom; + const pad = 200; // overdraw padding for glow/labels + + // ─── Reuse cached maps (avoid per-frame allocation) ─────────────── + const nodeMap = nodeMapCache.current; + nodeMap.clear(); + for (const n of state.nodes) nodeMap.set(n.id, n); + + const edgeMap = edgeMapCache.current; + edgeMap.clear(); + for (const e of state.edges) edgeMap.set(e.id, e); + + // ─── Filter visible nodes (frustum cull) — reuse array ──────────── + const visibleNodes = visibleNodesCache.current; + visibleNodes.length = 0; + for (const n of state.nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + if (x > viewLeft - pad && x < viewRight + pad && + y > viewTop - pad && y < viewBottom + pad) { + visibleNodes.push(n); + } + } + + // ─── Active particle edges — reuse Set ─────────────────────────── + const activeParticleEdges = activeParticleEdgesCache.current; + activeParticleEdges.clear(); + for (const p of state.particles) activeParticleEdges.add(p.edgeId); + + // ─── Draw ───────────────────────────────────────────────────────── + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + // 1. Background (screen space) + updateDepthParticles(starsRef.current, w, h, state.time > 0 ? 0.016 : 0); + drawBackground(ctx, w, h, starsRef.current, cam, state.time, { + showHexGrid, + showStarField, + }); + + // 2. World-space content + ctx.save(); + ctx.translate(cam.x, cam.y); + ctx.scale(zoom, zoom); + + // 2a. Edges (only those connecting visible nodes) — reuse collections + const visibleNodeIds = visibleNodeIdsCache.current; + visibleNodeIds.clear(); + for (const n of visibleNodes) visibleNodeIds.add(n.id); + + const visibleEdges = visibleEdgesCache.current; + visibleEdges.length = 0; + for (const e of state.edges) { + if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { + visibleEdges.push(e); + } + } + drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); + + // 2b. Particles (cap at 100 for performance) + const cappedParticles = state.particles.length > 100 + ? state.particles.slice(-100) + : state.particles; + drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); + + // 2c. Visible nodes only (back to front: process → task → member/lead) + drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawColumnHeaders(ctx, KanbanLayoutEngine.zones); + drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + + // 2d. Effects + drawEffects(ctx, state.effects); + + ctx.restore(); // world space + ctx.restore(); // DPR scale + + // 3. Bloom post-processing — always active for space aesthetic + if (bloomIntensity > 0) { + bloomRef.current.apply(canvas, ctx); + } + + // 4. Performance overlay (enabled via ?perf in URL) + const perf = perfRef.current; + const frameMs = performance.now() - frameStart; + perf.frameTimes.push(frameMs); + perf.frames++; + if (perf.frameTimes.length > 120) perf.frameTimes.shift(); + + const now = performance.now(); + if (now - perf.lastFpsUpdate > 1000) { + perf.fps = perf.frames; + perf.frames = 0; + perf.lastFpsUpdate = now; + const sorted = [...perf.frameTimes].sort((a, b) => a - b); + perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0; + } + + if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) { + ctx.save(); + ctx.scale(dpr, dpr); + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(w - 130, 4, 126, 48); + ctx.font = '10px monospace'; + ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566'; + ctx.textAlign = 'right'; + ctx.fillText(`${perf.fps} fps`, w - 10, 18); + ctx.fillStyle = '#aaeeff'; + ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32); + ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46); + ctx.restore(); + } + + } catch (err) { + // Rate-limited error logging — max once per 5 seconds + const now = performance.now(); + if (now - lastDrawErrorRef.current > 5000) { + lastDrawErrorRef.current = now; + console.error('[AgentGraph] Draw error:', err); + } + } + }, + getCanvas: () => canvasRef.current, + }), [showHexGrid, showStarField, bloomIntensity]); + + // Wheel handler (passive: false required for preventDefault) + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !onWheel) return; + const handler = (e: WheelEvent) => { + e.preventDefault(); + onWheel(e); + }; + canvas.addEventListener('wheel', handler, { passive: false }); + return () => canvas.removeEventListener('wheel', handler); + }, [onWheel]); + + return ( +
+ +
+ ); +}); diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx new file mode 100644 index 00000000..135d0acf --- /dev/null +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -0,0 +1,266 @@ +/** + * GraphControls — floating toolbar over the canvas. + * Positioned below system buttons (top-10) to avoid overlap on macOS. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Columns3, + Expand, + Settings2, + Eye, + EyeOff, + Maximize2, + Pause, + Pin, + Play, + Server, + X, + ZoomIn, + ZoomOut, +} from 'lucide-react'; + +export interface GraphFilterState { + showTasks: boolean; + showProcesses: boolean; + showEdges: boolean; + paused: boolean; +} + +export interface GraphControlsProps { + filters: GraphFilterState; + onFiltersChange: (filters: GraphFilterState) => void; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomToFit: () => void; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; + onRequestFullscreen?: () => void; + teamName: string; + teamColor?: string; + isAlive?: boolean; +} + +export function GraphControls({ + filters, + onFiltersChange, + onZoomIn, + onZoomOut, + onZoomToFit, + onRequestClose, + onRequestPinAsTab, + onRequestFullscreen, + teamName, + teamColor, + isAlive, +}: GraphControlsProps): React.JSX.Element { + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsRef = useRef(null); + const toggle = useCallback( + (key: keyof GraphFilterState) => { + onFiltersChange({ ...filters, [key]: !filters[key] }); + }, + [filters, onFiltersChange], + ); + + useEffect(() => { + if (!isSettingsOpen) return; + + const handlePointerDown = (event: MouseEvent): void => { + const target = event.target as Node | null; + if (!target) return; + if (settingsRef.current?.contains(target)) return; + setIsSettingsOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsSettingsOpen(false); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isSettingsOpen]); + + const nameColor = teamColor ?? '#aaeeff'; + + return ( + <> +
+
+ {isAlive && ( +
+ )} + + {teamName} + +
+
+ +
+
+ toggle('paused')} + icon={filters.paused ? : } + /> +
+ +
+
+ setIsSettingsOpen((value) => !value)} + icon={} + label="View" + active={isSettingsOpen} + /> +
+ + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && } />} + {onRequestFullscreen && ( + } + label="Fullscreen" + /> + )} + {onRequestClose && } />} +
+
+ +
+
+ } /> + } label="Fit" /> + } /> +
+
+ + ); +} + +// ─── Primitives ───────────────────────────────────────────────────────────── + +function ToolbarButton({ + onClick, + icon, + label, + active = false, +}: { + onClick?: () => void; + icon: React.ReactNode; + label?: string; + active?: boolean; +}): React.JSX.Element { + return ( + + ); +} + +function ToolbarToggle({ + active, + onClick, + icon, + label, + block = false, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + block?: boolean; +}): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx new file mode 100644 index 00000000..0cee384c --- /dev/null +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -0,0 +1,76 @@ +/** + * GraphOverlay — minimal built-in popover fallback. + * Used ONLY when host app doesn't provide renderOverlay prop. + * For full-featured popovers, use renderOverlay with project UI components. + */ + +import type { GraphNode } from '../ports/types'; +import type { GraphEventPort } from '../ports/GraphEventPort'; + +export interface GraphOverlayProps { + selectedNode: GraphNode | null; + events?: GraphEventPort; + onDeselect: () => void; +} + +export function GraphOverlay({ + selectedNode, + events, + onDeselect, +}: GraphOverlayProps): React.JSX.Element | null { + if (!selectedNode) return null; + + return ( +
+
+ {selectedNode.label} +
+ {selectedNode.sublabel && ( +
+ {selectedNode.sublabel} +
+ )} + {selectedNode.role && ( +
+ {selectedNode.role} +
+ )} +
+ {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( + { + const ref = selectedNode.domainRef; + if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); + onDeselect(); + }} + /> + )} + +
+
+ ); +} + +function FallbackButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx new file mode 100644 index 00000000..90bd07a2 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -0,0 +1,471 @@ +/** + * GraphView — main orchestrator with UNIFIED RAF loop. + * + * ARCHITECTURE: One RAF loop that: + * 1. Ticks d3-force simulation (updates node positions in refs) + * 2. Updates particles and effects (in refs) + * 3. Calls canvasRef.draw() imperatively (no React re-renders) + * + * React useState ONLY for: selectedNodeId, filters (user-facing UI state). + * ALL animation state (positions, particles, effects, time) lives in refs. + */ + +import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; +import type { GraphDataPort } from '../ports/GraphDataPort'; +import type { GraphEventPort } from '../ports/GraphEventPort'; +import type { GraphConfigPort } from '../ports/GraphConfigPort'; +import type { GraphNode } from '../ports/types'; +import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; +import { GraphControls, type GraphFilterState } from './GraphControls'; +import { GraphOverlay } from './GraphOverlay'; +import { useGraphSimulation } from '../hooks/useGraphSimulation'; +import { useGraphCamera } from '../hooks/useGraphCamera'; +import { useGraphInteraction } from '../hooks/useGraphInteraction'; +import { findNodeAt } from '../canvas/hit-detection'; +import { ANIM_SPEED } from '../constants/canvas-constants'; + +export interface GraphViewProps { + data: GraphDataPort; + events?: GraphEventPort; + config?: Partial; + className?: string; + suspendAnimation?: boolean; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; + onRequestFullscreen?: () => void; + /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ + renderOverlay?: (props: { + node: GraphNode; + screenPos: { x: number; y: number }; + onClose: () => void; + }) => React.ReactNode; +} + +export function GraphView({ + data, + events, + config, + className, + suspendAnimation = false, + onRequestClose, + onRequestPinAsTab, + onRequestFullscreen, + renderOverlay, +}: GraphViewProps): React.JSX.Element { + // ─── React state (user-facing only) ───────────────────────────────────── + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [filters, setFilters] = useState({ + showTasks: config?.showTasks ?? true, + showProcesses: config?.showProcesses ?? true, + showEdges: true, + paused: !(config?.animationEnabled ?? true), + }); + const effectivePaused = filters.paused || suspendAnimation; + + // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change + const selectedNodeIdRef = useRef(null); + selectedNodeIdRef.current = selectedNodeId; + + const containerRef = useRef(null); + const canvasHandle = useRef(null); + const overlayRef = useRef(null); + const rafRef = useRef(0); + const lastTimeRef = useRef(0); + const runningRef = useRef(false); + const hasAutoFit = useRef(false); + const allowAutoFitRef = useRef(true); + + // ─── Hooks ────────────────────────────────────────────────────────────── + const simulation = useGraphSimulation(); + const camera = useGraphCamera(); + + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + + const interaction = useGraphInteraction( + useCallback((nodeId: string, x: number, y: number) => { + const state = simulation.stateRef.current; + const node = state.nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = x; + node.fy = y; + node.x = x; + node.y = y; + } + }, [simulation.stateRef]), + ); + + // ─── Sync data from adapter → simulation ──────────────────────────────── + useEffect(() => { + const filteredNodes = data.nodes.filter((n) => { + if (n.kind === 'task' && !filters.showTasks) return false; + if (n.kind === 'process' && !filters.showProcesses) return false; + return true; + }); + const filteredEdges = filters.showEdges + ? data.edges + : data.edges.filter((e) => e.type === 'parent-child'); + simulation.updateData(filteredNodes, filteredEdges, data.particles); + }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); + + // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── + const idleFrameSkip = useRef(0); + + const animate = useCallback(() => { + if (!runningRef.current) return; + + const now = performance.now() / 1000; + const dt = Math.min( + lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime, + ANIM_SPEED.maxDeltaTime, + ); + lastTimeRef.current = now; + + // 1. Tick simulation + simulationRef.current.tick(dt); + + // 2. Update camera inertia + cameraRef.current.updateInertia(); + + // 3. Adaptive frame rate: skip every other frame when idle (no particles, no effects, sim settled) + const state = simulationRef.current.stateRef.current; + const isIdle = state.particles.length === 0 && state.effects.length === 0; + if (isIdle) { + idleFrameSkip.current++; + if (idleFrameSkip.current % 2 !== 0) { + rafRef.current = requestAnimationFrame(animate); + return; // skip draw, halve fps when idle + } + } else { + idleFrameSkip.current = 0; + } + + // 4. Draw canvas imperatively (NO React re-render) + canvasHandle.current?.draw({ + nodes: state.nodes, + edges: state.edges, + particles: state.particles, + effects: state.effects, + time: state.time, + camera: cameraRef.current.transformRef.current, + selectedNodeId: selectedNodeIdRef.current, + hoveredNodeId: interaction.hoveredNodeId.current, + }); + + rafRef.current = requestAnimationFrame(animate); + // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs + }, []); + + // Start/stop RAF + useEffect(() => { + if (!effectivePaused) { + runningRef.current = true; + lastTimeRef.current = 0; + rafRef.current = requestAnimationFrame(animate); + } else { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + } + return () => { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + }; + }, [effectivePaused, animate]); + + const fitGraphToViewport = useCallback(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }, [camera, data.nodes.length, simulation.stateRef]); + + // ─── Auto-fit: until first user interaction, also react to container resizes ───── + useEffect(() => { + if (data.nodes.length === 0) { + hasAutoFit.current = false; + allowAutoFitRef.current = true; + return; + } + + if (!hasAutoFit.current) { + hasAutoFit.current = true; + fitGraphToViewport(); + + const raf1 = requestAnimationFrame(() => { + fitGraphToViewport(); + requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + return () => cancelAnimationFrame(raf1); + } + }, [data.nodes.length, fitGraphToViewport]); + + useEffect(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + + let frame = 0; + const observer = new ResizeObserver(() => { + if (!allowAutoFitRef.current) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + observer.observe(el); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [data.nodes.length, fitGraphToViewport]); + + const markUserInteracted = useCallback(() => { + allowAutoFitRef.current = false; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + markUserInteracted(); + camera.handleWheel(e); + }, [camera, markUserInteracted]); + + // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ + const isPanningRef = useRef(false); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; // only left click + + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + + // Check if we hit a node + interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); + + // Hit a node (draggable or clickable) → don't pan + const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + if (hitNode) { + markUserInteracted(); + isPanningRef.current = false; + } else { + // Hit empty space → pan + markUserInteracted(); + isPanningRef.current = true; + camera.handlePanStart(e.clientX, e.clientY); + } + }, [camera, interaction, markUserInteracted, simulation.stateRef]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + // Dragging with left button held + if (e.buttons & 1) { + if (isPanningRef.current) { + camera.handlePanMove(e.clientX, e.clientY); + return; + } + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + return; + } + + // No button held — hover detection + cursor update + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; + }, [camera, interaction, simulation.stateRef]); + + const handleMouseUp = useCallback(() => { + if (isPanningRef.current) { + camera.handlePanEnd(); + isPanningRef.current = false; + setSelectedNodeId(null); // hide popover after pan + return; + } + + const clickedId = interaction.handleMouseUp(); + if (clickedId) { + setSelectedNodeId(clickedId); + const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); + if (node) events?.onNodeClick?.(node.domainRef); + } else { + setSelectedNodeId(null); // click on empty space — hide popover + if (!interaction.isDragging.current) { + events?.onBackgroundClick?.(); + } + } + }, [interaction, simulation.stateRef, events, camera]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); + if (nodeId) { + const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); + if (node) { + // Unpin if pinned (toggle) + if (node.fx != null) { + node.fx = null; + node.fy = null; + } + events?.onNodeDoubleClick?.(node.domainRef); + } + } + }, [camera, interaction, simulation.stateRef, events]); + + // ─── Keyboard ─────────────────────────────────────────────────────────── + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture from inputs + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + + if (e.key === 'Escape') { + if (selectedNodeId) { + setSelectedNodeId(null); + } else { + onRequestClose?.(); + } + } + if (e.key === 'f' || e.key === 'F') { + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + if (e.key === ' ') { + e.preventDefault(); + setFilters((f) => ({ ...f, paused: !f.paused })); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + + // ─── Selected node for overlay ────────────────────────────────────────── + const selectedNode: GraphNode | null = + selectedNodeId + ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null + : null; + + useLayoutEffect(() => { + if (!selectedNode || !containerRef.current || !overlayRef.current) { + return; + } + + const container = containerRef.current; + const floating = overlayRef.current; + + const reference = { + getBoundingClientRect(): DOMRect { + const containerRect = container.getBoundingClientRect(); + const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + return DOMRect.fromRect({ + x: containerRect.left + screenPos.x, + y: containerRect.top + screenPos.y, + width: 0, + height: 0, + }); + }, + }; + + const updatePosition = async (): Promise => { + const { x, y } = await computePosition(reference, floating, { + strategy: 'fixed', + placement: 'right-start', + middleware: [ + offset(16), + flip({ + boundary: container, + padding: 12, + fallbackPlacements: ['left-start', 'bottom-start', 'top-start'], + }), + shift({ + boundary: container, + padding: 12, + }), + ], + }); + + floating.style.left = `${x}px`; + floating.style.top = `${y}px`; + }; + + const cleanup = autoUpdate(reference, floating, updatePosition, { + animationFrame: true, + }); + + void updatePosition(); + + return cleanup; + }, [camera, selectedNode]); + + // ─── Render ───────────────────────────────────────────────────────────── + return ( +
+ + + { + markUserInteracted(); + camera.zoomIn(); + }} + onZoomOut={() => { + markUserInteracted(); + camera.zoomOut(); + }} + onZoomToFit={() => { + markUserInteracted(); + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }} + onRequestClose={onRequestClose} + onRequestPinAsTab={onRequestPinAsTab} + onRequestFullscreen={onRequestFullscreen} + teamName={data.teamName} + teamColor={data.teamColor} + isAlive={data.isAlive} + /> + + {selectedNode && ( +
+ {renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + )} +
+ )} +
+ ); +} diff --git a/packages/agent-graph/tsconfig.json b/packages/agent-graph/tsconfig.json new file mode 100644 index 00000000..4196ea0e --- /dev/null +++ b/packages/agent-graph/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977139de..f81f1a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@claude-teams/agent-graph': + specifier: workspace:* + version: link:packages/agent-graph '@codemirror/autocomplete': specifier: ^6.20.0 version: 6.20.0 @@ -515,6 +518,22 @@ importers: specifier: ^3.1.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(sass@1.98.0)(terser@5.46.0) + packages/agent-graph: + dependencies: + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + packages: 7zip-bin@5.2.0: @@ -9045,6 +9064,11 @@ packages: rc9@3.0.0: resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -9111,6 +9135,10 @@ packages: '@types/react': optional: true + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -9377,6 +9405,9 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -21042,6 +21073,12 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -21121,6 +21158,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.4: {} read-binary-file-arch@1.0.6: @@ -21496,6 +21537,10 @@ snapshots: sax@1.6.0: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} scslre@0.3.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73c2b7d9..e8e77a86 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - agent-teams-controller - mcp-server - landing + - packages/agent-graph ignoredBuiltDependencies: - esbuild diff --git a/src/main/index.ts b/src/main/index.ts index a6eeda5d..29d569db 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import './sentry'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; +import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; @@ -30,6 +31,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; +import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { CONTEXT_CHANGED, @@ -37,6 +39,7 @@ import { SKILLS_CHANGED, SSH_STATUS, TEAM_CHANGE, + TEAM_PROJECT_BRANCH_CHANGE, TEAM_TOOL_APPROVAL_EVENT, WINDOW_FULLSCREEN_CHANGED, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload @@ -84,8 +87,15 @@ import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; +import { + clearRendererAvailability, + markRendererReady, + markRendererUnavailable, + safeSendToRenderer, +} from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { + BranchStatusService, CliInstallerService, configManager, LocalFileSystemProvider, @@ -97,6 +107,8 @@ import { SshConnectionManager, TaskBoundaryParser, TeamDataService, + TeamLogSourceTracker, + TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, @@ -382,6 +394,9 @@ let httpServer: HttpServer; let schedulerService: SchedulerService; let skillsWatcherService: SkillsWatcherService | null = null; let teamBackupService: TeamBackupService | null = null; +let branchStatusService: BranchStatusService | null = null; +let rendererRecoveryTimer: ReturnType | null = null; +let rendererRecoveryAttempts = 0; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; @@ -472,9 +487,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // ignore } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('file-change', event); - } + safeSendToRenderer(mainWindow, 'file-change', event); httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); @@ -488,9 +501,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above) const todoChangeHandler = (event: unknown): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('todo-change', event); - } + safeSendToRenderer(mainWindow, 'todo-change', event); httpServer?.broadcast('todo-change', event); }; context.fileWatcher.on('todo-change', todoChangeHandler); @@ -498,9 +509,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // Forward team-change events to renderer and HTTP SSE const teamChangeHandler = (event: unknown): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_CHANGE, event); - } + safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); // Process inbox and task change events. @@ -648,13 +657,11 @@ function onContextSwitched(context: ServiceContext): void { rewireContextEvents(context); // Notify renderer of context change - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus()); - mainWindow.webContents.send(CONTEXT_CHANGED, { - id: context.id, - type: context.type, - }); - } + safeSendToRenderer(mainWindow, SSH_STATUS, sshConnectionManager.getStatus()); + safeSendToRenderer(mainWindow, CONTEXT_CHANGED, { + id: context.id, + type: context.type, + }); } /** @@ -753,6 +760,8 @@ function initializeServices(): void { ptyTerminalService = new PtyTerminalService(); teamDataService = new TeamDataService(); teamProvisioningService = new TeamProvisioningService(); + // Startup GC: remove stale MCP config files from previous sessions (best-effort) + void new TeamMcpConfigBuilder().gcStaleConfigs(); void teamDataService .initializeTaskCommentNotificationState() .catch((error: unknown) => @@ -780,9 +789,17 @@ function initializeServices(): void { teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); + const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); + let teammateToolTracker: TeammateToolTracker | null = null; + branchStatusService = new BranchStatusService((event) => { + safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event); + }); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); + teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); + changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); const gitDiffFallback = new GitDiffFallback(); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); @@ -833,32 +850,39 @@ function initializeServices(): void { return getTeamControlApiBaseUrl(); }); - // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). - const teamChangeEmitter = (event: TeamChangeEvent): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_CHANGE, event); - } + const forwardTeamChange = (event: TeamChangeEvent): void => { + safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; + teammateToolTracker = new TeammateToolTracker( + teamMemberLogsFinder, + teamLogSourceTracker, + forwardTeamChange + ); + // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). + const teamChangeEmitter = (event: TeamChangeEvent): void => { + forwardTeamChange(event); + if (event.type === 'lead-activity' && event.detail === 'offline') { + teammateToolTracker?.handleTeamOffline(event.teamName); + } + }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + teamLogSourceTracker.setEmitter(teamChangeEmitter); + teamLogSourceTracker.onLogSourceChange((teamName) => { + teammateToolTracker?.handleLogSourceChange(teamName); + }); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SCHEDULE_CHANGE, event); - } + safeSendToRenderer(mainWindow, SCHEDULE_CHANGE, event); }); skillsWatcherService.setEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SKILLS_CHANGED, event); - } + safeSendToRenderer(mainWindow, SKILLS_CHANGED, event); }); teamProvisioningService.setToolApprovalEventEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); - } + safeSendToRenderer(mainWindow, TEAM_TOOL_APPROVAL_EVENT, event); }); teamProvisioningService.setMainWindow(mainWindow); @@ -875,6 +899,8 @@ function initializeServices(): void { teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, + teammateToolTracker ?? undefined, + branchStatusService ?? undefined, { rewire: rewireContextEvents, full: onContextSwitched, @@ -911,9 +937,7 @@ function initializeServices(): void { // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SSH_STATUS, status); - } + safeSendToRenderer(mainWindow, SSH_STATUS, status); httpServer.broadcast('ssh:status', status); }); @@ -987,6 +1011,9 @@ function shutdownServices(): void { teamProvisioningService.stopAllTeams(); } + // Best-effort cleanup of MCP config files owned by this process + void new TeamMcpConfigBuilder().gcOwnConfigs(); + // Sync backup all team data (files are stable after SIGKILL). if (teamBackupService) { teamBackupService.runShutdownBackupSync(); @@ -1029,6 +1056,8 @@ function shutdownServices(): void { if (teamDataService) { teamDataService.stopProcessHealthPolling(); } + branchStatusService?.dispose(); + branchStatusService = null; // Stop scheduled task execution and croner jobs if (schedulerService) { @@ -1061,7 +1090,35 @@ function syncTrafficLightPosition(win: BrowserWindow): void { if (process.platform === 'darwin') { win.setWindowButtonPosition(position); } - win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); + safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); +} + +function scheduleRendererRecovery(win: BrowserWindow): void { + if (rendererRecoveryTimer) { + return; + } + if (rendererRecoveryAttempts >= 2) { + logger.error('Renderer recovery limit reached; skipping automatic reload'); + return; + } + + rendererRecoveryAttempts += 1; + const delayMs = rendererRecoveryAttempts * 1000; + logger.warn(`Scheduling renderer recovery attempt ${rendererRecoveryAttempts} in ${delayMs}ms`); + + rendererRecoveryTimer = setTimeout(() => { + rendererRecoveryTimer = null; + if (!mainWindow || mainWindow !== win || win.isDestroyed()) { + return; + } + + markRendererUnavailable(win); + try { + win.webContents.reload(); + } catch (error) { + logger.error(`Renderer recovery reload failed: ${String(error)}`); + } + }, delayMs); } /** @@ -1090,6 +1147,7 @@ function createWindow(): void { ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'Claude Agent Teams UI', }); + markRendererUnavailable(mainWindow); // In dev, forward selected renderer console warnings/errors to the main terminal. // Use the new single-argument event payload to avoid Electron deprecation warnings. @@ -1147,25 +1205,30 @@ function createWindow(): void { // Notify renderer when entering/leaving fullscreen (so traffic light padding can be removed) mainWindow.on('enter-full-screen', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, true); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, true); }); mainWindow.on('leave-full-screen', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, false); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, false); + }); + + mainWindow.webContents.on('did-start-loading', () => { + markRendererUnavailable(mainWindow); + branchStatusService?.resetAllTracking(); }); // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { + markRendererReady(mainWindow); + rendererRecoveryAttempts = 0; + if (rendererRecoveryTimer) { + clearTimeout(rendererRecoveryTimer); + rendererRecoveryTimer = null; + } logger.warn('[startup] renderer did-finish-load'); syncTrafficLightPosition(mainWindow); setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen()); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen()); }, 0); // Start file watchers now that the window is visible and responsive. // Deferred from initializeServices() to avoid blocking window creation @@ -1234,7 +1297,7 @@ function createWindow(): void { // Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts if (isMod && input.key.toLowerCase() === 'n') { event.preventDefault(); - mainWindow.webContents.send('review:cmdN'); + safeSendToRenderer(mainWindow, 'review:cmdN'); return; } @@ -1264,6 +1327,11 @@ function createWindow(): void { }); mainWindow.on('closed', () => { + if (rendererRecoveryTimer) { + clearTimeout(rendererRecoveryTimer); + rendererRecoveryTimer = null; + } + clearRendererAvailability(mainWindow); mainWindow = null; // Clear main window references if (notificationManager) { @@ -1290,7 +1358,13 @@ function createWindow(): void { // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) mainWindow.webContents.on('render-process-gone', (_event, details) => { logger.error('Renderer process gone:', details.reason, details.exitCode); - // Could show an error dialog or attempt to reload the window + markRendererUnavailable(mainWindow); + branchStatusService?.resetAllTracking(); + const activeContext = contextRegistry.getActive(); + activeContext?.stopFileWatcher(); + if (mainWindow) { + scheduleRendererRecovery(mainWindow); + } }); // Set main window reference for notification manager and updater diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 64806f83..de4d5fda 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -10,6 +10,7 @@ import { CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, + CLI_INSTALLER_INVALIDATE_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -39,6 +40,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer export function registerCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); + ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); logger.info('CLI installer handlers registered'); } @@ -49,6 +51,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { export function removeCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); ipcMain.removeHandler(CLI_INSTALLER_INSTALL); + ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); logger.info('CLI installer handlers removed'); } @@ -105,3 +108,8 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise { + cachedStatus = null; + return { success: true, data: undefined }; +} diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 120df2ae..cd85dc94 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -7,6 +7,7 @@ import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { isPathWithinRoot } from '@main/utils/pathValidation'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { EDITOR_CHANGE, EDITOR_CLOSE, @@ -367,9 +368,7 @@ async function handleEditorWatchDir( } // Forward event to renderer - if (mainWindowRef && !mainWindowRef.isDestroyed()) { - mainWindowRef.webContents.send(EDITOR_CHANGE, event); - } + safeSendToRenderer(mainWindowRef, EDITOR_CHANGE, event); }); } else { editorFileWatcher.stop(); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9f57c9dc..c959a381 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -88,6 +88,7 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { + BranchStatusService, ChangeExtractorService, CliInstallerService, FileContentResolver, @@ -99,6 +100,7 @@ import type { ServiceContextRegistry, SshConnectionManager, TeamDataService, + TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, @@ -127,6 +129,8 @@ export function initializeIpcHandlers( teamProvisioningService: TeamProvisioningService, teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, + teammateToolTracker: TeammateToolTracker | undefined, + branchStatusService: BranchStatusService | undefined, contextCallbacks: { rewire: (context: ServiceContext) => void; full: (context: ServiceContext) => void; @@ -167,7 +171,9 @@ export function initializeIpcHandlers( teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, - teamBackupService + teamBackupService, + teammateToolTracker, + branchStatusService ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 7abb5ccc..a2acf0f4 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -8,6 +8,7 @@ import { createIpcWrapper } from '@main/ipc/ipcWrapper'; import { EditorFileWatcher } from '@main/services/editor'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { validateFilePath } from '@main/utils/pathValidation'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, @@ -401,9 +402,7 @@ async function handleWatchReviewFiles( reviewFileWatcher.stop(); reviewWatcherProjectRoot = normalizedProjectPath; reviewFileWatcher.start(normalizedProjectPath, (event) => { - if (reviewMainWindowRef && !reviewMainWindowRef.isDestroyed()) { - reviewMainWindowRef.webContents.send(REVIEW_FILE_CHANGE, event); - } + safeSendToRenderer(reviewMainWindowRef, REVIEW_FILE_CHANGE, event); }); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f28bb017..4848902b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -26,6 +26,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -46,10 +47,14 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_RESPOND, @@ -75,6 +80,10 @@ import { } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { + buildStandaloneSlashCommandMeta, + parseStandaloneSlashCommand, +} from '@shared/utils/slashCommands'; import crypto from 'crypto'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; @@ -102,8 +111,10 @@ import { } from './guards'; import type { + BranchStatusService, MemberStatsComputer, TeamDataService, + TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, } from '../services'; @@ -265,6 +276,8 @@ let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; +let teammateToolTracker: TeammateToolTracker | null = null; +let branchStatusService: BranchStatusService | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); @@ -293,18 +306,26 @@ export function initializeTeamHandlers( provisioningService: TeamProvisioningService, logsFinder?: TeamMemberLogsFinder, statsComputer?: MemberStatsComputer, - backupService?: TeamBackupService + backupService?: TeamBackupService, + toolTracker?: TeammateToolTracker, + branchTracker?: BranchStatusService ): void { teamDataService = service; teamProvisioningService = provisioningService; teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; + teammateToolTracker = toolTracker ?? null; + branchStatusService = branchTracker ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); + ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); + ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking); + ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); @@ -332,6 +353,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); + ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); @@ -366,6 +388,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); + ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); + ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING); + ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); @@ -393,6 +419,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); + ipcMain.removeHandler(TEAM_START_TASK_BY_USER); ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); @@ -437,6 +464,20 @@ function getTeamProvisioningService(): TeamProvisioningService { return teamProvisioningService; } +function getTeammateToolTracker(): TeammateToolTracker { + if (!teammateToolTracker) { + throw new Error('Teammate tool tracker is not initialized'); + } + return teammateToolTracker; +} + +function getBranchStatusService(): BranchStatusService { + if (!branchStatusService) { + throw new Error('Branch status service is not initialized'); + } + return branchStatusService; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -610,6 +651,73 @@ async function handleGetData( return { success: true, data: { ...data, isAlive, messages: merged } }; } +async function handleGetTaskChangePresence( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise>> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getTaskChangePresence', () => + getTeamDataService().getTaskChangePresence(validated.value!) + ); +} + +async function handleSetChangePresenceTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setChangePresenceTracking', async () => { + getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled); + }); +} + +async function handleSetProjectBranchTracking( + _event: IpcMainInvokeEvent, + projectPath: unknown, + enabled: unknown +): Promise> { + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return { success: false, error: 'projectPath must be a non-empty string' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setProjectBranchTracking', async () => { + await getBranchStatusService().setTracking(projectPath.trim(), enabled); + }); +} + +async function handleSetToolActivityTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setToolActivityTracking', async () => { + await getTeammateToolTracker().setTracking(validated.value!, enabled); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown @@ -1371,25 +1479,38 @@ async function handleSendMessage( const preGeneratedMessageId = crypto.randomUUID(); // Separate try blocks: stdin delivery vs persistence // If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) - // Wrap with instructions so lead responds with visible text (not just agent-only blocks) - const wrappedText = [ - `You received a direct message from the user.`, - `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, - AGENT_BLOCK_OPEN, - `MessageId: ${preGeneratedMessageId}`, - `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, - AGENT_BLOCK_CLOSE, - ``, - `Message from user:`, - buildMessageDeliveryText(payload.text!, { - actionMode, - isLeadRecipient: true, - }), - ].join('\n'); + const standaloneSlashCommand = !validatedAttachments?.length + ? parseStandaloneSlashCommand(payload.text!) + : null; + const slashCommandMeta = standaloneSlashCommand + ? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw) + : null; + const rawSlashCommandText = standaloneSlashCommand?.raw; + const stdinTextForLead = rawSlashCommandText + ? rawSlashCommandText + : [ + `You received a direct message from the user.`, + `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, + AGENT_BLOCK_OPEN, + `MessageId: ${preGeneratedMessageId}`, + `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, + AGENT_BLOCK_CLOSE, + ``, + `Message from user:`, + buildMessageDeliveryText(payload.text!, { + actionMode, + isLeadRecipient: true, + }), + ].join('\n'); + const persistTextForLead = rawSlashCommandText ?? payload.text!; let stdinSent = false; try { - await provisioning.sendMessageToTeam(tn, wrappedText, validatedAttachments); + await provisioning.sendMessageToTeam( + tn, + stdinTextForLead, + rawSlashCommandText ? undefined : validatedAttachments + ); stdinSent = true; } catch (stdinError: unknown) { // Stdin failed (process died between check and write) @@ -1436,7 +1557,7 @@ async function handleSendMessage( result = await getTeamDataService().sendDirectToLead( tn, resolvedLeadName, - payload.text!, + persistTextForLead, payload.summary, attachmentMeta, validatedTaskRefs.value, @@ -1452,7 +1573,7 @@ async function handleSendMessage( provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', to: resolvedLeadName, - text: payload.text!, + text: persistTextForLead, timestamp: new Date().toISOString(), read: true, summary: payload.summary, @@ -1460,6 +1581,12 @@ async function handleSendMessage( source: 'user_sent', attachments: attachmentMeta, taskRefs: validatedTaskRefs.value, + ...(slashCommandMeta + ? { + messageKind: 'slash_command' as const, + slashCommand: slashCommandMeta, + } + : {}), }); return result; @@ -2116,6 +2243,24 @@ async function handleStartTask( ); } +async function handleStartTaskByUser( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('startTaskByUser', () => + getTeamDataService().startTaskByUser(validatedTeamName.value!, validatedTaskId.value!) + ); +} + async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:getAllTasks'); const startedAt = Date.now(); @@ -2800,8 +2945,12 @@ async function handleToolApprovalRespond( async function handleToolApprovalSettings( _event: IpcMainInvokeEvent, + teamName: unknown, settings: unknown ): Promise> { + if (typeof teamName !== 'string' || teamName.trim().length === 0) { + return { success: false, error: 'teamName must be a non-empty string' }; + } if (typeof settings !== 'object' || settings === null) { return { success: false, error: 'Settings must be an object' }; } @@ -2828,7 +2977,10 @@ async function handleToolApprovalSettings( } try { - getTeamProvisioningService().updateToolApprovalSettings(s as unknown as ToolApprovalSettings); + getTeamProvisioningService().updateToolApprovalSettings( + teamName, + s as unknown as ToolApprovalSettings + ); } catch (err) { return { success: false, diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 3227604d..0b3ac09b 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -71,6 +71,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP const logger = createLogger('Discovery:ProjectScanner'); +/** How long to reuse the cached project list for search (ms) */ +const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; + // IPC payload safety: session ID arrays can be extremely large for long-lived projects. // Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive. // Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list @@ -147,6 +150,9 @@ export class ProjectScanner { private scanCache: { projects: Project[]; timestamp: number } | null = null; private static readonly SCAN_CACHE_TTL_MS = 2000; + /** Cached project list for search — avoids re-scanning disk on every query */ + private searchProjectCache: { projects: Project[]; timestamp: number } | null = null; + // Platform-aware batch sizes to avoid UV thread pool saturation on Windows private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12; @@ -984,6 +990,12 @@ export class ProjectScanner { ? firstMessageTimestampMs : birthtimeMs; + // If messages suggest ongoing but the file hasn't been written to in 5+ minutes, + // the session likely crashed/was killed (upstream fix #94) + const STALE_SESSION_THRESHOLD_MS = 5 * 60 * 1000; + const isOngoing = + metadata.isOngoing && Date.now() - effectiveMtime < STALE_SESSION_THRESHOLD_MS; + return { id: sessionId, projectId, @@ -993,7 +1005,7 @@ export class ProjectScanner { messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, - isOngoing: metadata.isOngoing, + isOngoing, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, @@ -1323,8 +1335,17 @@ export class ProjectScanner { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } - // Get all projects - const projects = await this.scan(); + // Use cached project list to avoid re-scanning disk on every keystroke + let projects: Project[]; + if ( + this.searchProjectCache && + Date.now() - this.searchProjectCache.timestamp < SEARCH_PROJECT_CACHE_TTL_MS + ) { + projects = this.searchProjectCache.projects; + } else { + projects = await this.scan(); + this.searchProjectCache = { projects, timestamp: Date.now() }; + } if (projects.length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; @@ -1332,7 +1353,7 @@ export class ProjectScanner { // Search across all projects with bounded concurrency const allResults: SearchSessionsResult[] = []; - const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4; + const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 8; for (let i = 0; i < projects.length; i += searchBatchSize) { const batch = projects.slice(i, i + searchBatchSize); diff --git a/src/main/services/discovery/SearchTextCache.ts b/src/main/services/discovery/SearchTextCache.ts index 68c61ba9..e48916bd 100644 --- a/src/main/services/discovery/SearchTextCache.ts +++ b/src/main/services/discovery/SearchTextCache.ts @@ -21,7 +21,7 @@ export class SearchTextCache { private readonly cache = new Map(); private readonly maxSize: number; - constructor(maxSize: number = 200) { + constructor(maxSize: number = 1000) { this.maxSize = maxSize; } diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 5f0615f1..0d1b1857 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -15,10 +15,6 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import { - extractMarkdownPlainText, - findMarkdownSearchMatches, -} from '@shared/utils/markdownTextSearch'; import * as path from 'path'; import { startMainSpan } from '../../sentry'; @@ -112,7 +108,7 @@ export class SessionSearcher { sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); // Search session files with bounded concurrency and staged breadth in SSH mode. - const searchBatchSize = fastMode ? 3 : 8; + const searchBatchSize = fastMode ? 3 : 16; const stageBoundaries = fastMode ? this.buildFastSearchStageBoundaries(sessionFiles.length) : [sessionFiles.length]; @@ -236,6 +232,10 @@ export class SessionSearcher { const { entries, sessionTitle } = cached; + // Fast pre-filter: skip sessions where no entry contains the query in raw text + const hasAnyMatch = entries.some((entry) => entry.text.toLowerCase().includes(query)); + if (!hasAnyMatch) return results; + for (const entry of entries) { if (results.length >= maxResults) break; @@ -262,31 +262,20 @@ export class SessionSearcher { sessionId: string, sessionTitle?: string ): void { - const mdMatches = findMarkdownSearchMatches(entry.text, query); - if (mdMatches.length === 0) return; + // Plain indexOf search — no markdown/remark parsing + const lowerText = entry.text.toLowerCase(); + if (!lowerText.includes(query)) return; - // Build plain text once for context snippet extraction - const plainText = extractMarkdownPlainText(entry.text); - const lowerPlain = plainText.toLowerCase(); - - for (const mdMatch of mdMatches) { + // Use raw text directly for context snippets + let pos = 0; + let matchIndex = 0; + while ((pos = lowerText.indexOf(query, pos)) !== -1) { if (results.length >= maxResults) return; - // Find approximate position in plain text for context extraction - let pos = 0; - for (let i = 0; i < mdMatch.matchIndexInItem; i++) { - const idx = lowerPlain.indexOf(query, pos); - if (idx === -1) break; - pos = idx + query.length; - } - const matchPos = lowerPlain.indexOf(query, pos); - const effectivePos = matchPos >= 0 ? matchPos : 0; - - const contextStart = Math.max(0, effectivePos - 50); - const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50); - const context = plainText.slice(contextStart, contextEnd); - const matchedText = - matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query; + const contextStart = Math.max(0, pos - 50); + const contextEnd = Math.min(entry.text.length, pos + query.length + 50); + const context = entry.text.slice(contextStart, contextEnd); + const matchedText = entry.text.slice(pos, pos + query.length); results.push({ sessionId, @@ -294,15 +283,20 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + + context + + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, itemType: entry.itemType, - matchIndexInItem: mdMatch.matchIndexInItem, - matchStartOffset: effectivePos, + matchIndexInItem: matchIndex, + matchStartOffset: pos, messageUuid: entry.messageUuid, }); + + matchIndex++; + pos += query.length; } } diff --git a/src/main/services/error/TriggerMatcher.ts b/src/main/services/error/TriggerMatcher.ts index 7e06c47c..dbf53900 100644 --- a/src/main/services/error/TriggerMatcher.ts +++ b/src/main/services/error/TriggerMatcher.ts @@ -11,6 +11,43 @@ import { type ContentBlock, type ParsedMessage } from '@main/types'; import { createSafeRegExp } from '@main/utils/regexValidation'; +// ============================================================================= +// Regex Cache +// ============================================================================= + +const MAX_CACHE_SIZE = 500; + +/** + * Module-level cache for compiled RegExp objects. + * Key: `${pattern}\0${flags}` (null byte separator avoids collisions). + * Value: compiled RegExp, or null if the pattern is invalid/dangerous. + */ +const regexCache = new Map(); + +/** + * Returns a cached RegExp for the given pattern and flags. + * Compiles and caches on first access; returns null for invalid patterns. + * Cache is bounded to MAX_CACHE_SIZE entries (oldest evicted first via Map insertion order). + */ +function getCachedRegex(pattern: string, flags: string): RegExp | null { + const key = `${pattern}\0${flags}`; + if (regexCache.has(key)) { + return regexCache.get(key) ?? null; + } + + // Evict oldest entries when cache is full + if (regexCache.size >= MAX_CACHE_SIZE) { + const firstKey = regexCache.keys().next().value; + if (firstKey !== undefined) { + regexCache.delete(firstKey); + } + } + + const regex = createSafeRegExp(pattern, flags); + regexCache.set(key, regex); + return regex; +} + // ============================================================================= // Pattern Matching // ============================================================================= @@ -18,9 +55,10 @@ import { createSafeRegExp } from '@main/utils/regexValidation'; /** * Checks if content matches a pattern. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesPattern(content: string, pattern: string): boolean { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (!regex) { // Pattern is invalid or potentially dangerous, reject match return false; @@ -31,6 +69,7 @@ export function matchesPattern(content: string, pattern: string): boolean { /** * Checks if content matches any of the ignore patterns. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean { if (!ignorePatterns || ignorePatterns.length === 0) { @@ -38,7 +77,7 @@ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[] } for (const pattern of ignorePatterns) { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (regex?.test(content)) { return true; } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index b8723175..4e4b0d8f 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -22,6 +22,7 @@ import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getCachedShellEnv, getShellPreferredHome, @@ -678,9 +679,7 @@ export class CliInstallerService { // --------------------------------------------------------------------------- private sendProgress(progress: CliInstallerProgress): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send(CLI_INSTALLER_PROGRESS_CHANNEL, progress); - } + safeSendToRenderer(this.mainWindow, CLI_INSTALLER_PROGRESS_CHANNEL, progress); } private detectPlatform(): CliPlatform { diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index c76543bd..4e3c1db7 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -17,6 +17,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; @@ -481,7 +482,7 @@ export class NotificationManager extends EventEmitter { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.show(); this.mainWindow.focus(); - this.mainWindow.webContents.send('notification:clicked', stored); + safeSendToRenderer(this.mainWindow, 'notification:clicked', stored); } this.emit('notification-clicked', stored); } @@ -556,9 +557,7 @@ export class NotificationManager extends EventEmitter { * Emits a notification:new event to the renderer. */ private emitNewNotification(notification: StoredNotification): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('notification:new', notification); - } + safeSendToRenderer(this.mainWindow, 'notification:new', notification); this.emit('notification-new', notification); } @@ -567,12 +566,10 @@ export class NotificationManager extends EventEmitter { * Emits a notification:updated event to the renderer. */ private emitNotificationUpdated(): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('notification:updated', { - total: this.notifications.length, - unreadCount: this.getUnreadCountSync(), - }); - } + safeSendToRenderer(this.mainWindow, 'notification:updated', { + total: this.notifications.length, + unreadCount: this.getUnreadCountSync(), + }); this.emit('notification-updated', { total: this.notifications.length, diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 7e50e3de..ba23ebb5 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -7,7 +7,9 @@ import crypto from 'node:crypto'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -65,9 +67,7 @@ export class PtyTerminalService { rows: options?.rows ?? 24, cwd: options?.cwd ?? home, env: { - ...process.env, - HOME: home, - USERPROFILE: home, + ...buildEnrichedEnv(), ...options?.env, } as Record, }); @@ -111,8 +111,6 @@ export class PtyTerminalService { } private send(channel: string, ...args: unknown[]): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send(channel, ...args); - } + safeSendToRenderer(this.mainWindow, channel, ...args); } } diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 51cc4f63..2c6f86bc 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -3,22 +3,69 @@ * * Forwards update lifecycle events to the renderer via IPC. * Auto-download is disabled so users must confirm before downloading. + * + * Before notifying the renderer about a new version, verifies that the + * platform-specific installer asset actually exists in the GitHub release. + * This prevents showing "update available" while CI is still uploading + * artifacts for the current platform. */ +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import electronUpdater from 'electron-updater'; const { autoUpdater } = electronUpdater; +import { app, net } from 'electron'; + import type { UpdaterStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; const logger = createLogger('UpdaterService'); +const REPO_OWNER = '777genius'; +const REPO_NAME = 'claude_agent_teams_ui'; + +/** + * Build the expected download URL for the platform-specific installer asset. + * Returns null if the current platform is unrecognized. + */ +function getExpectedAssetUrl(version: string): string | null { + const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`; + + switch (process.platform) { + case 'darwin': + return process.arch === 'arm64' + ? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg` + : `${base}/Claude.Agent.Teams.UI-${version}.dmg`; + case 'win32': + return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`; + case 'linux': + return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`; + default: + return null; + } +} + +/** + * Check if a remote URL exists using a HEAD request. + * Follows redirects (GitHub releases use 302 → S3). + */ +async function assetExists(url: string): Promise { + try { + const response = await net.fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } +} + export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | null = null; + private downloadedVersion: string | null = null; constructor() { autoUpdater.autoDownload = false; @@ -64,6 +111,17 @@ export class UpdaterService { * isForceRunAfter=true launches the app after install. Other platforms ignore these. */ quitAndInstall(): void { + if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) { + logger.warn( + `Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}` + ); + this.sendStatus({ + type: 'error', + error: 'Refused to install a non-newer app version.', + }); + return; + } + autoUpdater.quitAndInstall(true, true); } @@ -89,9 +147,45 @@ export class UpdaterService { } private sendStatus(status: UpdaterStatus): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('updater:status', status); + safeSendToRenderer(this.mainWindow, 'updater:status', status); + } + + private isNewerThanCurrent(candidateVersion: string): boolean { + return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion)); + } + + /** + * Verify that the platform-specific asset exists before notifying the renderer. + * If CI hasn't finished uploading the artifact for this OS yet, suppress the + * notification — the next periodic check will retry. + */ + private async verifyAndNotify(info: { + version: string; + releaseNotes?: string | unknown; + }): Promise { + if (!this.isNewerThanCurrent(info.version)) { + logger.warn( + `Suppressing non-newer update notification. current=${app.getVersion()} candidate=${info.version}` + ); + return; } + + const url = getExpectedAssetUrl(info.version); + if (url) { + const exists = await assetExists(url); + if (!exists) { + logger.warn( + `Asset not yet available for ${process.platform}/${process.arch}, suppressing update notification (${url})` + ); + return; + } + } + + this.sendStatus({ + type: 'available', + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + }); } private bindEvents(): void { @@ -102,11 +196,7 @@ export class UpdaterService { autoUpdater.on('update-available', (info) => { logger.info('Update available:', info.version); - this.sendStatus({ - type: 'available', - version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, - }); + void this.verifyAndNotify(info); }); autoUpdater.on('update-not-available', () => { @@ -126,6 +216,14 @@ export class UpdaterService { }); autoUpdater.on('update-downloaded', (info) => { + if (!this.isNewerThanCurrent(info.version)) { + logger.warn( + `Ignoring downloaded non-newer update. current=${app.getVersion()} downloaded=${info.version}` + ); + return; + } + + this.downloadedVersion = info.version; logger.info('Update downloaded:', info.version); this.sendStatus({ type: 'downloaded', diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 03043082..3200c1af 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -463,9 +463,15 @@ class GitIdentityResolver { * @param projectPath - The filesystem path to check * @returns Branch name or null */ - async getBranch(projectPath: string): Promise { + async getBranch( + projectPath: string, + options?: { + forceRefresh?: boolean; + } + ): Promise { + const forceRefresh = options?.forceRefresh === true; const cached = this.branchCache.get(projectPath); - if (cached && cached.expiry > Date.now()) { + if (!forceRefresh && cached && cached.expiry > Date.now()) { return cached.value; } diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index 093dcaaf..fc5c85bd 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -85,21 +85,42 @@ export class SessionParser { * Process parsed messages into structured data. */ private processMessages(messages: ParsedMessage[]): ParsedSession { - // Group by type + // Single-pass categorization instead of 8 separate filter passes const byType = { - user: messages.filter((m) => m.type === 'user'), - realUser: messages.filter(isParsedRealUserMessage), - internalUser: messages.filter(isParsedInternalUserMessage), - assistant: messages.filter((m) => m.type === 'assistant'), - system: messages.filter((m) => m.type === 'system'), - other: messages.filter( - (m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system' - ), + user: [] as ParsedMessage[], + realUser: [] as ParsedMessage[], + internalUser: [] as ParsedMessage[], + assistant: [] as ParsedMessage[], + system: [] as ParsedMessage[], + other: [] as ParsedMessage[], }; + const sidechainMessages: ParsedMessage[] = []; + const mainMessages: ParsedMessage[] = []; - // Separate sidechain and main messages - const sidechainMessages = messages.filter((m) => m.isSidechain); - const mainMessages = messages.filter((m) => !m.isSidechain); + for (const m of messages) { + switch (m.type) { + case 'user': + byType.user.push(m); + if (isParsedRealUserMessage(m)) byType.realUser.push(m); + if (isParsedInternalUserMessage(m)) byType.internalUser.push(m); + break; + case 'assistant': + byType.assistant.push(m); + break; + case 'system': + byType.system.push(m); + break; + default: + byType.other.push(m); + break; + } + + if (m.isSidechain) { + sidechainMessages.push(m); + } else { + mainMessages.push(m); + } + } // Calculate metrics const metrics = calculateMetrics(messages); diff --git a/src/main/services/team/BranchStatusService.ts b/src/main/services/team/BranchStatusService.ts new file mode 100644 index 00000000..544ec4f2 --- /dev/null +++ b/src/main/services/team/BranchStatusService.ts @@ -0,0 +1,132 @@ +import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; + +import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; + +import type { ProjectBranchChangeEvent } from '@shared/types'; + +const logger = createLogger('Service:BranchStatus'); +const POLL_INTERVAL_MS = 20_000; + +interface BranchResolver { + getBranch(projectPath: string, options?: { forceRefresh?: boolean }): Promise; +} + +interface TrackedPathState { + actualPath: string; + refCount: number; + token: number; +} + +const UNSET_BRANCH = Symbol('unset-branch'); + +export class BranchStatusService { + private readonly trackedPaths = new Map(); + private readonly inFlightChecks = new Map>(); + private readonly lastEmittedBranchByPath = new Map(); + private pollTimer: ReturnType | null = null; + private nextToken = 1; + + constructor( + private readonly emitBranchChange: (event: ProjectBranchChangeEvent) => void, + private readonly resolver: BranchResolver = gitIdentityResolver + ) {} + + async setTracking(projectPath: string, enabled: boolean): Promise { + const trimmed = projectPath.trim(); + if (!trimmed) return; + const normalizedPath = path.normalize(trimmed); + + if (!enabled) { + this.unsubscribe(normalizedPath); + return; + } + + const existing = this.trackedPaths.get(normalizedPath); + if (existing) { + existing.refCount += 1; + return; + } + this.trackedPaths.set(normalizedPath, { + actualPath: normalizedPath, + refCount: 1, + token: this.nextToken++, + }); + this.startPollingIfNeeded(); + await this.checkPath(normalizedPath, false); + } + + dispose(): void { + this.resetAllTracking(); + } + + resetAllTracking(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.trackedPaths.clear(); + this.inFlightChecks.clear(); + this.lastEmittedBranchByPath.clear(); + } + + private unsubscribe(normalizedPath: string): void { + const existing = this.trackedPaths.get(normalizedPath); + if (!existing) return; + existing.refCount -= 1; + if (existing.refCount > 0) return; + this.trackedPaths.delete(normalizedPath); + this.inFlightChecks.delete(normalizedPath); + this.lastEmittedBranchByPath.delete(normalizedPath); + if (this.trackedPaths.size === 0 && this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private startPollingIfNeeded(): void { + if (this.pollTimer || this.trackedPaths.size === 0) return; + this.pollTimer = setInterval(() => { + for (const normalizedPath of this.trackedPaths.keys()) { + void this.checkPath(normalizedPath, true); + } + }, POLL_INTERVAL_MS); + } + + private async checkPath(normalizedPath: string, forceRefresh: boolean): Promise { + const tracked = this.trackedPaths.get(normalizedPath); + if (!tracked) return; + const expectedToken = tracked.token; + if (this.inFlightChecks.has(normalizedPath)) { + return this.inFlightChecks.get(normalizedPath); + } + + const promise = (async () => { + try { + const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh }); + const latestTracked = this.trackedPaths.get(normalizedPath); + if (!latestTracked || latestTracked.token !== expectedToken) return; + + const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH; + if (previous !== UNSET_BRANCH && previous === branch) { + return; + } + + this.lastEmittedBranchByPath.set(normalizedPath, branch); + this.emitBranchChange({ + projectPath: latestTracked.actualPath, + branch, + }); + } catch (error) { + logger.debug( + `Failed to resolve branch for ${normalizedPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + })().finally(() => { + this.inFlightChecks.delete(normalizedPath); + }); + + this.inFlightChecks.set(normalizedPath, promise); + return promise; + } +} diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index fe63d20d..c8b5f162 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -6,27 +6,30 @@ import { type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; -import { createReadStream } from 'fs'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; -import * as readline from 'readline'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; +import { TaskChangeComputer } from './TaskChangeComputer'; +import { + buildTaskChangePresenceDescriptor, + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; +import { getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; +import { + type ResolvedTaskChangeComputeInput, + type TaskChangeEffectiveOptions, + type TaskChangeTaskMeta, +} from './taskChangeWorkerTypes'; import { TeamConfigReader } from './TeamConfigReader'; -import { countLineChanges } from './UnifiedLineCounter'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { - AgentChangeSet, - ChangeStats, - FileChangeSummary, - FileEditEvent, - FileEditTimeline, - SnippetDiff, - TaskChangeScope, - TaskChangeSetV2, -} from '@shared/types'; +import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); @@ -42,13 +45,6 @@ interface TaskChangeSummaryCacheEntry { expiresAt: number; } -interface ParsedSnippetsCacheEntry { - data: SnippetDiff[]; - mtime: number; - expiresAt: number; -} - -/** Ссылка на JSONL файл с привязкой к memberName */ interface LogFileRef { filePath: string; memberName: string; @@ -60,22 +56,34 @@ export class ChangeExtractorService { private taskChangeSummaryInFlight = new Map>(); private taskChangeSummaryVersionByTask = new Map(); private taskChangeSummaryValidationInFlight = new Set(); - private parsedSnippetsCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk private readonly taskChangeSummaryCacheTtl = 60 * 1000; private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000; private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000; private readonly maxTaskChangeSummaryCacheEntries = 200; - private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets private readonly isPersistedTaskChangeCacheEnabled = process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0'; + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; + private readonly taskChangeComputer: TaskChangeComputer; constructor( private readonly logsFinder: TeamMemberLogsFinder, - private readonly boundaryParser: TaskBoundaryParser, + boundaryParser: TaskBoundaryParser, private readonly configReader: TeamConfigReader = new TeamConfigReader(), - private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository() - ) {} + private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(), + private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient() + ) { + this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); + } + + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } /** Получить все изменения агента */ async getAgentChanges(teamName: string, memberName: string): Promise { @@ -85,37 +93,12 @@ export class ChangeExtractorService { return cached.data; } - const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); const projectPath = await this.resolveProjectPath(teamName); - - // Собираем все snippets из всех JSONL файлов параллельно - const parseResults = await this.parseJSONLFilesWithConcurrency(paths); - let latestMtime = 0; - const merged: SnippetDiff[] = []; - for (const r of parseResults) { - merged.push(...r.snippets); - if (r.mtime > latestMtime) latestMtime = r.mtime; - } - const allSnippets = this.sortSnippetsChronologically(merged); - - const files = this.aggregateByFile(allSnippets, projectPath); - - let totalLinesAdded = 0; - let totalLinesRemoved = 0; - for (const file of files) { - totalLinesAdded += file.linesAdded; - totalLinesRemoved += file.linesRemoved; - } - - const result: AgentChangeSet = { + const { result, latestMtime } = await this.taskChangeComputer.computeAgentChanges( teamName, memberName, - files, - totalLinesAdded, - totalLinesRemoved, - totalFiles: files.length, - computedAt: new Date().toISOString(), - }; + projectPath + ); this.cache.set(cacheKey, { data: result, @@ -140,14 +123,16 @@ export class ChangeExtractorService { forceFresh?: boolean; } ): Promise { + const initialVersion = this.getTaskChangeSummaryVersion(teamName, taskId); const includeDetails = options?.summaryOnly !== true; const taskMeta = await this.readTaskMeta(teamName, taskId); - const effectiveOptions = { + const effectiveOptions: TaskChangeEffectiveOptions = { owner: options?.owner ?? taskMeta?.owner, status: options?.status ?? taskMeta?.status, intervals: options?.intervals ?? taskMeta?.intervals, since: options?.since, }; + const projectPath = await this.resolveProjectPath(teamName); const effectiveStateBucket = taskMeta ? getTaskChangeStateBucket({ status: effectiveOptions.status, @@ -162,14 +147,27 @@ export class ChangeExtractorService { const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket); const shouldUseSummaryCache = !includeDetails && summaryCacheableState; + let version = initialVersion; if (!summaryCacheableState || options?.forceFresh === true) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true, }); + version = this.getTaskChangeSummaryVersion(teamName, taskId); } + const resolvedInput: ResolvedTaskChangeComputeInput = { + teamName, + taskId, + taskMeta, + effectiveOptions, + projectPath, + includeDetails, + }; + if (!shouldUseSummaryCache) { - return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails); + const result = await this.computeTaskChangesPreferred(resolvedInput); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); + return result; } const cacheKey = this.buildTaskChangeSummaryCacheKey( @@ -178,11 +176,17 @@ export class ChangeExtractorService { effectiveOptions, effectiveStateBucket ); - const version = this.getTaskChangeSummaryVersion(teamName, taskId); if (options?.forceFresh !== true) { const cached = this.taskChangeSummaryCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + cached.data + ); return cached.data; } this.taskChangeSummaryCache.delete(cacheKey); @@ -201,11 +205,18 @@ export class ChangeExtractorService { ); if (persisted) { this.setTaskChangeSummaryCache(cacheKey, persisted); + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + persisted + ); return persisted; } } - const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, false) + const promise = this.computeTaskChangesPreferred({ ...resolvedInput, includeDetails: false }) .then(async (result) => { if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { return result; @@ -220,6 +231,7 @@ export class ChangeExtractorService { result, version ); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); return result; }) .finally(() => { @@ -256,101 +268,41 @@ export class ChangeExtractorService { ); } - private async computeTaskChanges( - teamName: string, - taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, - includeDetails: boolean + private async computeTaskChangesPreferred( + input: ResolvedTaskChangeComputeInput ): Promise { - const taskMeta = await this.readTaskMeta(teamName, taskId); - const logRefs = await this.logsFinder.findLogFileRefsForTask( - teamName, - taskId, - effectiveOptions - ); - if (logRefs.length === 0) { - return this.emptyTaskChangeSet(teamName, taskId); + if (!this.taskChangeWorkerClient.isAvailable()) { + return this.taskChangeComputer.computeTaskChanges(input); } - const projectPath = await this.resolveProjectPath(teamName); - - // Парсим boundaries для каждого лог-файла и ищем scope данной задачи - const allScopes: TaskChangeScope[] = []; - for (const ref of logRefs) { - const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); - const scope = boundaries.scopes.find((s) => s.taskId === taskId); - if (scope) { - allScopes.push({ ...scope, memberName: ref.memberName }); + try { + const result = await this.taskChangeWorkerClient.computeTaskChanges(input); + if (this.isValidWorkerTaskChangeResult(result, input)) { + return result; } + logger.warn( + `Task change worker returned malformed result for ${input.teamName}/${input.taskId}; falling back inline.` + ); + } catch (error) { + logger.warn( + `Task change worker failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` + ); } - // Если scope не найден — try deterministic interval scoping, else fallback to whole file - if (allScopes.length === 0) { - const intervals = effectiveOptions.intervals; - if (Array.isArray(intervals) && intervals.length > 0) { - const { files, toolUseIds, startTimestamp, endTimestamp } = - await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); + return this.taskChangeComputer.computeTaskChanges(input); + } - return { - teamName, - taskId, - files, - totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), - totalFiles: files.length, - confidence: 'medium', - computedAt: new Date().toISOString(), - scope: { - taskId, - memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', - startLine: 0, - endLine: 0, - startTimestamp, - endTimestamp, - toolUseIds, - filePaths: files.map((f) => f.filePath), - confidence: { - tier: 2, - label: 'medium', - reason: 'Scoped by persisted task workIntervals (timestamp-based)', - }, - }, - warnings: - files.length === 0 - ? ['No file edits found within persisted workIntervals.'] - : ['Task boundaries missing — scoped by workIntervals timestamps.'], - }; - } - - return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); - } - - const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); - const files = await this.extractFilteredChanges( - logRefs, - allowedToolUseIds, - projectPath, - includeDetails + private isValidWorkerTaskChangeResult( + result: TaskChangeSetV2, + input: ResolvedTaskChangeComputeInput + ): boolean { + return ( + !!result && + typeof result === 'object' && + result.teamName === input.teamName && + result.taskId === input.taskId && + Array.isArray(result.files) ); - - const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); - return { - teamName, - taskId, - files, - totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), - totalFiles: files.length, - confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', - computedAt: new Date().toISOString(), - scope: allScopes[0], - warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], - }; } /** Получить краткую статистику */ @@ -366,17 +318,7 @@ export class ChangeExtractorService { // ---- Private methods ---- /** Read task metadata (owner, status) from the task JSON file */ - private async readTaskMeta( - teamName: string, - taskId: string - ): Promise<{ - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; - historyEvents?: unknown[]; - kanbanColumn?: 'review' | 'approved'; - } | null> { + private async readTaskMeta(teamName: string, taskId: string): Promise { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const raw = await readFile(taskPath, 'utf8'); @@ -429,6 +371,7 @@ export class ChangeExtractorService { return derived.length > 0 ? derived : undefined; })(); return { + createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, intervals: derivedIntervals, @@ -460,606 +403,21 @@ export class ChangeExtractorService { } } - private async extractIntervalScopedChanges( - logRefs: LogFileRef[], - intervals: { startedAt: string; completedAt?: string }[], - projectPath?: string, - includeDetails = true - ): Promise<{ - files: FileChangeSummary[]; - toolUseIds: string[]; - startTimestamp: string; - endTimestamp: string; - }> { - const normalized: { - startMs: number; - endMs: number | null; - startedAt: string; - completedAt?: string; - }[] = []; - - for (const i of intervals) { - const startMs = Date.parse(i.startedAt); - if (!Number.isFinite(startMs)) continue; - const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; - const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; - normalized.push({ startMs, endMs, startedAt: i.startedAt, completedAt: i.completedAt }); - } - - normalized.sort((a, b) => a.startMs - b.startMs); - const startTimestamp = normalized[0]?.startedAt ?? ''; - - const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>((acc, it) => { - if (it.endMs == null || typeof it.completedAt !== 'string') return acc; - if (!acc || it.endMs > acc.endMs) return { endMs: it.endMs, endTimestamp: it.completedAt }; - return acc; - }, null); - const endTimestamp = maxEnd?.endTimestamp ?? ''; - - const inAnyInterval = (ts: string): boolean => { - const ms = Date.parse(ts); - if (!Number.isFinite(ms)) return false; - for (const it of normalized) { - if (ms < it.startMs) continue; - if (it.endMs == null) return true; - if (ms <= it.endMs) return true; - } - return false; - }; - - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allowedSnippets: SnippetDiff[] = []; - const toolUseIdsSet = new Set(); - - for (const { snippets } of allParsed) { - for (const s of snippets) { - if (s.isError) continue; - if (!inAnyInterval(s.timestamp)) continue; - allowedSnippets.push(s); - if (s.toolUseId) toolUseIdsSet.add(s.toolUseId); - } - } - - const files = this.aggregateByFile( - this.sortSnippetsChronologically(allowedSnippets), - projectPath, - includeDetails - ); - return { - files, - toolUseIds: [...toolUseIdsSet], - startTimestamp, - endTimestamp, - }; - } - - /** - * Compute a context hash from old/newString for reliable hunk↔snippet matching. - * Uses first+last 3 lines of both strings as a fingerprint. - */ - private computeContextHash(oldString: string, newString: string): string { - const take3 = (s: string): string => { - const lines = s.split('\n'); - const head = lines.slice(0, 3).join('\n'); - const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; - return `${head}|${tail}`; - }; - const raw = `${take3(oldString)}::${take3(newString)}`; - // Simple hash: DJB2 variant (fast, no crypto needed) - let hash = 5381; - for (let i = 0; i < raw.length; i++) { - hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0; - } - return (hash >>> 0).toString(36); - } - - /** Deterministic sort: timestamp → filePath → toolUseId → originalIndex */ - private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] { - return snippets - .map((snippet, originalIndex) => ({ snippet, originalIndex })) - .sort((a, b) => { - const aMs = Date.parse(a.snippet.timestamp); - const bMs = Date.parse(b.snippet.timestamp); - const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER; - const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER; - if (safeA !== safeB) return safeA - safeB; - if (a.snippet.filePath !== b.snippet.filePath) - return a.snippet.filePath.localeCompare(b.snippet.filePath); - if (a.snippet.toolUseId !== b.snippet.toolUseId) - return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId); - return a.originalIndex - b.originalIndex; - }) - .map(({ snippet }) => snippet); - } - - /** Parse multiple JSONL files with bounded concurrency (worker-pool) */ - private static readonly JSONL_PARSE_CONCURRENCY = 6; - - private async parseJSONLFilesWithConcurrency( - paths: string[] - ): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> { - if (paths.length === 0) return []; - - const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length); - let nextIndex = 0; - - const worker = async (): Promise => { - while (true) { - const currentIndex = nextIndex++; - if (currentIndex >= paths.length) return; - results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]); - } - }; - - await Promise.all( - Array.from( - { length: Math.min(ChangeExtractorService.JSONL_PARSE_CONCURRENCY, paths.length) }, - () => worker() - ) - ); - - return results; - } - - /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ - private async parseJSONLFile( - filePath: string - ): Promise<{ snippets: SnippetDiff[]; mtime: number }> { - let fileMtime = 0; - try { - const fileStat = await stat(filePath); - fileMtime = fileStat.mtimeMs; - const cached = this.parsedSnippetsCache.get(filePath); - if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) { - return { snippets: cached.data, mtime: fileMtime }; - } - } catch (err) { - logger.debug(`Не удалось stat файла ${filePath}: ${String(err)}`); - return { snippets: [], mtime: 0 }; - } - - // Сначала считываем все записи в память для двух проходов - const entries: Record[] = []; - - try { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - entries.push(JSON.parse(trimmed) as Record); - } catch { - // Пропускаем невалидный JSON - } - } - - rl.close(); - stream.destroy(); - } catch (err) { - logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`); - return { snippets: [], mtime: 0 }; - } - - // Проход 1: собираем tool_use_id с ошибками - const erroredIds = this.collectErroredToolUseIds(entries); - - // Проход 2: извлекаем snippets из tool_use блоков - const snippets: SnippetDiff[] = []; - // Множество уже встречавшихся файлов (для определения write-new vs write-update) - const seenFiles = new Set(); - - for (const entry of entries) { - const role = this.extractRole(entry); - if (role !== 'assistant') continue; - - const content = this.extractContent(entry); - if (!content) continue; - - const timestamp = - typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(); - - for (const block of content) { - if ( - !block || - typeof block !== 'object' || - (block as Record).type !== 'tool_use' - ) { - continue; - } - - const toolBlock = block as Record; - const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : ''; - // Убираем proxy_ префикс - const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName; - const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : ''; - const input = toolBlock.input as Record | undefined; - if (!input) continue; - - const isError = erroredIds.has(toolUseId); - - if (toolName === 'Edit') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const oldString = typeof input.old_string === 'string' ? input.old_string : ''; - const newString = typeof input.new_string === 'string' ? input.new_string : ''; - const replaceAll = input.replace_all === true; - - if (targetPath) { - seenFiles.add(this.normalizeFilePathKey(targetPath)); - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'Edit', - type: 'edit', - oldString, - newString, - replaceAll, - timestamp, - isError, - contextHash: this.computeContextHash(oldString, newString), - }); - } - } else if (toolName === 'Write') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const writeContent = typeof input.content === 'string' ? input.content : ''; - - if (targetPath) { - const normalizedTargetPath = this.normalizeFilePathKey(targetPath); - const isNew = !seenFiles.has(normalizedTargetPath); - seenFiles.add(normalizedTargetPath); - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'Write', - type: isNew ? 'write-new' : 'write-update', - oldString: '', - newString: writeContent, - replaceAll: false, - timestamp, - isError, - contextHash: this.computeContextHash('', writeContent), - }); - } - } else if (toolName === 'MultiEdit') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const edits = Array.isArray(input.edits) ? input.edits : []; - - if (targetPath) { - seenFiles.add(this.normalizeFilePathKey(targetPath)); - for (const edit of edits) { - if (!edit || typeof edit !== 'object') continue; - const editObj = edit as Record; - const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; - const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'MultiEdit', - type: 'multi-edit', - oldString, - newString, - replaceAll: false, - timestamp, - isError, - contextHash: this.computeContextHash(oldString, newString), - }); - } - } - } - // Остальные инструменты (NotebookEdit и пр.) пропускаем - } - } - - this.parsedSnippetsCache.set(filePath, { - data: snippets, - mtime: fileMtime, - expiresAt: Date.now() + this.parsedSnippetsCacheTtl, - }); - - return { snippets, mtime: fileMtime }; - } - - /** Извлечь content array из JSONL entry (оба формата: subagent и main) */ - private extractContent(entry: Record): unknown[] | null { - const message = entry.message as Record | undefined; - if (message && Array.isArray(message.content)) return message.content as unknown[]; - if (Array.isArray(entry.content)) return entry.content as unknown[]; - return null; - } - - /** Извлечь роль из JSONL entry */ - private extractRole(entry: Record): string | null { - if (typeof entry.role === 'string') return entry.role; - const message = entry.message as Record | undefined; - if (message && typeof message.role === 'string') return message.role; - return null; - } - - /** Собрать errored tool_use_ids из tool_result блоков */ - private collectErroredToolUseIds(entries: Record[]): Set { - const erroredIds = new Set(); - - for (const entry of entries) { - // tool_result может находиться в entry.content (когда это массив) - if (Array.isArray(entry.content)) { - for (const block of entry.content) { - if (this.isErroredToolResult(block)) { - const toolUseId = (block as Record).tool_use_id; - if (typeof toolUseId === 'string') { - erroredIds.add(toolUseId); - } - } - } - } - - // Также проверяем entry.message.content - const message = entry.message as Record | undefined; - if (message && Array.isArray(message.content)) { - for (const block of message.content) { - if (this.isErroredToolResult(block)) { - const toolUseId = (block as Record).tool_use_id; - if (typeof toolUseId === 'string') { - erroredIds.add(toolUseId); - } - } - } - } - } - - return erroredIds; - } - - /** Проверить, является ли блок tool_result с ошибкой */ - private isErroredToolResult(block: unknown): boolean { - if (!block || typeof block !== 'object') return false; - const obj = block as Record; - return obj.type === 'tool_result' && obj.is_error === true; - } - - /** Агрегировать snippets в FileChangeSummary[] */ - private aggregateByFile( - snippets: SnippetDiff[], - projectPath?: string, - includeDetails = true - ): FileChangeSummary[] { - const fileMap = new Map< - string, - { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } - >(); - - for (const snippet of snippets) { - // Пропускаем snippets с ошибками при агрегации - if (snippet.isError) continue; - - const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath); - const existing = fileMap.get(normalizedFilePath); - if (existing) { - existing.snippets.push(snippet); - if (snippet.type === 'write-new') existing.isNewFile = true; - } else { - fileMap.set(normalizedFilePath, { - filePath: snippet.filePath, - snippets: [snippet], - isNewFile: snippet.type === 'write-new', - }); - } - } - - return [...fileMap.values()].map((data) => { - const fp = data.filePath; - let totalAdded = 0; - let totalRemoved = 0; - for (const s of data.snippets) { - if (s.isError) continue; - const { added, removed } = countLineChanges(s.oldString, s.newString); - totalAdded += added; - totalRemoved += removed; - } - // Normalize separators for cross-platform path stripping - const normalizedFp = fp.replace(/\\/g, '/'); - const normalizedProject = projectPath?.replace(/\\/g, '/'); - const relative = normalizedProject - ? normalizedFp.startsWith(normalizedProject + '/') - ? normalizedFp.slice(normalizedProject.length + 1) - : normalizedFp.startsWith(normalizedProject) - ? normalizedFp.slice(normalizedProject.length) - : normalizedFp.split('/').slice(-3).join('/') - : normalizedFp.split('/').slice(-3).join('/'); - return { - filePath: fp, - relativePath: relative, - snippets: includeDetails ? data.snippets : [], - linesAdded: totalAdded, - linesRemoved: totalRemoved, - isNewFile: data.isNewFile, - timeline: includeDetails ? this.buildTimeline(fp, data.snippets) : undefined, - }; - }); - } - - /** Build edit timeline from snippets */ - private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { - const events: FileEditEvent[] = snippets - .filter((s) => !s.isError) - .map((s, idx) => { - const { added, removed } = countLineChanges(s.oldString, s.newString); - return { - toolUseId: s.toolUseId, - toolName: s.toolName as FileEditEvent['toolName'], - timestamp: s.timestamp, - summary: this.generateEditSummary(s), - linesAdded: added, - linesRemoved: removed, - snippetIndex: idx, - }; - }); - - const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t)); - const durationMs = - timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; - - return { filePath, events, durationMs }; - } - - private generateEditSummary(snippet: SnippetDiff): string { - switch (snippet.type) { - case 'write-new': - return 'Created new file'; - case 'write-update': - return 'Wrote full file content'; - case 'multi-edit': { - const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); - const total = added + removed; - return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; - } - case 'edit': { - const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); - if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; - if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; - return `Changed ${removed} → ${added} lines`; - } - default: - return 'File modified'; - } - } - - /** Проверить, содержит ли путь к файлу один из sessionId */ - private pathMatchesAnySession(filePath: string, sessionIds: Set): boolean { - for (const sessionId of sessionIds) { - if (filePath.includes(sessionId)) return true; - } - return false; - } - - /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ - private async extractFilteredChanges( - logRefs: LogFileRef[], - allowedToolUseIds: Set, - projectPath?: string, - includeDetails = true - ): Promise { - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allSnippets: SnippetDiff[] = []; - for (const { snippets } of allParsed) { - if (allowedToolUseIds.size > 0) { - for (const s of snippets) { - if (allowedToolUseIds.has(s.toolUseId)) { - allSnippets.push(s); - } - } - } else { - allSnippets.push(...snippets); - } - } - return this.aggregateByFile( - this.sortSnippetsChronologically(allSnippets), - projectPath, - includeDetails - ); - } - - /** Извлечь все изменения из одного файла */ - private async extractAllChanges( - filePath: string, - _memberName: string, - projectPath?: string, - includeDetails = true - ): Promise { - const { snippets } = await this.parseJSONLFile(filePath); - return this.aggregateByFile(snippets, projectPath, includeDetails); - } - - /** Fallback: вернуть все изменения из лог-файлов как Tier 4 */ - private async fallbackSingleTaskScope( - teamName: string, - taskId: string, - logRefs: LogFileRef[], - projectPath?: string, - includeDetails = true - ): Promise { - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allSnippets = this.sortSnippetsChronologically(allParsed.flatMap((r) => r.snippets)); - const allFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); - - const fallbackScope: TaskChangeScope = { - taskId, - memberName: logRefs[0]?.memberName ?? 'unknown', - startLine: 1, - endLine: 0, - startTimestamp: '', - endTimestamp: '', - toolUseIds: [], - filePaths: allFiles.map((f) => f.filePath), - confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, - }; - - return { - teamName, - taskId, - files: allFiles, - totalLinesAdded: allFiles.reduce((sum, f) => sum + f.linesAdded, 0), - totalLinesRemoved: allFiles.reduce((sum, f) => sum + f.linesRemoved, 0), - totalFiles: allFiles.length, - confidence: 'fallback', - computedAt: new Date().toISOString(), - scope: fallbackScope, - warnings: ['No task boundaries found — showing all changes from related sessions.'], - }; - } - - /** Пустой TaskChangeSetV2 */ - private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { - return { - teamName, - taskId, - files: [], - totalLinesAdded: 0, - totalLinesRemoved: 0, - totalFiles: 0, - confidence: 'fallback', - computedAt: new Date().toISOString(), - scope: { - taskId, - memberName: '', - startLine: 0, - endLine: 0, - startTimestamp: '', - endTimestamp: '', - toolUseIds: [], - filePaths: [], - confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, - }, - warnings: ['No log files found for this task.'], - }; - } - private buildTaskChangeSummaryCacheKey( teamName: string, taskId: string, - options: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + options: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket ): string { return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`; } private normalizeFilePathKey(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); + return normalizeTaskChangePresenceFilePath(filePath); } private buildTaskSignature( - options: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + options: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket ): string { const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; @@ -1131,19 +489,9 @@ export class ChangeExtractorService { private async readPersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, - taskMeta: { - status?: string; - reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; - historyEvents?: unknown[]; - kanbanColumn?: 'review' | 'approved'; - } | null + taskMeta: TaskChangeTaskMeta | null ): Promise { if (!this.isPersistedTaskChangeCacheEnabled) { return null; @@ -1197,12 +545,7 @@ export class ChangeExtractorService { private schedulePersistedTaskChangeSummaryValidation( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string ): void { @@ -1237,12 +580,7 @@ export class ChangeExtractorService { private async validatePersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string, version: number @@ -1282,12 +620,7 @@ export class ChangeExtractorService { private async persistTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, result: TaskChangeSetV2, generation: number @@ -1365,7 +698,59 @@ export class ChangeExtractorService { private async computeProjectFingerprint(teamName: string): Promise { const projectPath = await this.resolveProjectPath(teamName); - if (!projectPath) return null; - return createHash('sha256').update(this.normalizeFilePathKey(projectPath)).digest('hex'); + return computeTaskChangePresenceProjectFingerprint(projectPath); + } + + private async recordTaskChangePresence( + teamName: string, + taskId: string, + taskMeta: TaskChangeTaskMeta | null, + effectiveOptions: TaskChangeEffectiveOptions, + result: TaskChangeSetV2 + ): Promise { + if (!this.taskChangePresenceRepository || !this.teamLogSourceTracker || !taskMeta) { + return; + } + + const snapshot = await this.teamLogSourceTracker.ensureTracking(teamName); + if (!snapshot.projectFingerprint || !snapshot.logSourceGeneration) { + return; + } + + if ( + result.files.length === 0 && + result.confidence !== 'high' && + result.confidence !== 'medium' + ) { + return; + } + + const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: taskMeta.createdAt, + owner: effectiveOptions.owner ?? taskMeta.owner, + status: effectiveOptions.status ?? taskMeta.status, + intervals: effectiveOptions.intervals ?? taskMeta.intervals, + since: effectiveOptions.since, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }); + + const now = new Date().toISOString(); + await this.taskChangePresenceRepository.upsertEntry( + teamName, + { + projectFingerprint: snapshot.projectFingerprint, + logSourceGeneration: snapshot.logSourceGeneration, + writtenAt: now, + }, + { + taskId, + taskSignature: descriptor.taskSignature, + presence: result.files.length > 0 ? 'has_changes' : 'no_changes', + writtenAt: now, + logSourceGeneration: snapshot.logSourceGeneration, + } + ); } } diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts new file mode 100644 index 00000000..4d8cd308 --- /dev/null +++ b/src/main/services/team/TaskChangeComputer.ts @@ -0,0 +1,706 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import * as readline from 'readline'; + +import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils'; +import { countLineChanges } from './UnifiedLineCounter'; + +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { + AgentChangeSet, + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; + +const logger = createLogger('Service:TaskChangeComputer'); + +interface ParsedSnippetsCacheEntry { + data: SnippetDiff[]; + mtime: number; + expiresAt: number; +} + +interface LogFileRef { + filePath: string; + memberName: string; +} + +export class TaskChangeComputer { + private parsedSnippetsCache = new Map(); + private readonly parsedSnippetsCacheTtl = 20 * 1000; + private static readonly JSONL_PARSE_CONCURRENCY = 6; + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly boundaryParser: TaskBoundaryParser + ) {} + + async computeAgentChanges( + teamName: string, + memberName: string, + projectPath?: string + ): Promise<{ result: AgentChangeSet; latestMtime: number }> { + const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + const parseResults = await this.parseJSONLFilesWithConcurrency(paths); + let latestMtime = 0; + const merged: SnippetDiff[] = []; + + for (const result of parseResults) { + merged.push(...result.snippets); + if (result.mtime > latestMtime) { + latestMtime = result.mtime; + } + } + + const files = this.aggregateByFile(this.sortSnippetsChronologically(merged), projectPath); + const taskChangeResult = { + teamName, + memberName, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + computedAt: new Date().toISOString(), + } satisfies AgentChangeSet; + + return { result: taskChangeResult, latestMtime }; + } + + async computeTaskChanges(input: ResolvedTaskChangeComputeInput): Promise { + const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input; + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); + if (logRefs.length === 0) { + return this.emptyTaskChangeSet(teamName, taskId); + } + + const allScopes: TaskChangeScope[] = []; + for (const ref of logRefs) { + const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); + const scope = boundaries.scopes.find((candidate) => candidate.taskId === taskId); + if (scope) { + allScopes.push({ ...scope, memberName: ref.memberName }); + } + } + + if (allScopes.length === 0) { + const intervals = effectiveOptions.intervals; + if (Array.isArray(intervals) && intervals.length > 0) { + const { files, toolUseIds, startTimestamp, endTimestamp } = + await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); + + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + confidence: 'medium', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', + startLine: 0, + endLine: 0, + startTimestamp, + endTimestamp, + toolUseIds, + filePaths: files.map((file) => file.filePath), + confidence: { + tier: 2, + label: 'medium', + reason: 'Scoped by persisted task workIntervals (timestamp-based)', + }, + }, + warnings: + files.length === 0 + ? ['No file edits found within persisted workIntervals.'] + : ['Task boundaries missing — scoped by workIntervals timestamps.'], + }; + } + + return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); + } + + const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); + const files = await this.extractFilteredChanges( + logRefs, + allowedToolUseIds, + projectPath, + includeDetails + ); + + const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', + computedAt: new Date().toISOString(), + scope: allScopes[0], + warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], + }; + } + + private async extractIntervalScopedChanges( + logRefs: LogFileRef[], + intervals: { startedAt: string; completedAt?: string }[], + projectPath?: string, + includeDetails = true + ): Promise<{ + files: FileChangeSummary[]; + toolUseIds: string[]; + startTimestamp: string; + endTimestamp: string; + }> { + const normalized: { + startMs: number; + endMs: number | null; + startedAt: string; + completedAt?: string; + }[] = []; + + for (const interval of intervals) { + const startMs = Date.parse(interval.startedAt); + if (!Number.isFinite(startMs)) continue; + const endMsRaw = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + normalized.push({ + startMs, + endMs, + startedAt: interval.startedAt, + completedAt: interval.completedAt, + }); + } + + normalized.sort((a, b) => a.startMs - b.startMs); + const startTimestamp = normalized[0]?.startedAt ?? ''; + const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>( + (acc, item) => { + if (item.endMs == null || typeof item.completedAt !== 'string') return acc; + if (!acc || item.endMs > acc.endMs) { + return { endMs: item.endMs, endTimestamp: item.completedAt }; + } + return acc; + }, + null + ); + const endTimestamp = maxEnd?.endTimestamp ?? ''; + + const inAnyInterval = (timestamp: string): boolean => { + const ms = Date.parse(timestamp); + if (!Number.isFinite(ms)) return false; + for (const interval of normalized) { + if (ms < interval.startMs) continue; + if (interval.endMs == null) return true; + if (ms <= interval.endMs) return true; + } + return false; + }; + + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allowedSnippets: SnippetDiff[] = []; + const toolUseIdsSet = new Set(); + + for (const { snippets } of allParsed) { + for (const snippet of snippets) { + if (snippet.isError) continue; + if (!inAnyInterval(snippet.timestamp)) continue; + allowedSnippets.push(snippet); + if (snippet.toolUseId) { + toolUseIdsSet.add(snippet.toolUseId); + } + } + } + + return { + files: this.aggregateByFile( + this.sortSnippetsChronologically(allowedSnippets), + projectPath, + includeDetails + ), + toolUseIds: [...toolUseIdsSet], + startTimestamp, + endTimestamp, + }; + } + + private async extractFilteredChanges( + logRefs: LogFileRef[], + allowedToolUseIds: Set, + projectPath?: string, + includeDetails = true + ): Promise { + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allSnippets: SnippetDiff[] = []; + + for (const { snippets } of allParsed) { + if (allowedToolUseIds.size > 0) { + for (const snippet of snippets) { + if (allowedToolUseIds.has(snippet.toolUseId)) { + allSnippets.push(snippet); + } + } + } else { + allSnippets.push(...snippets); + } + } + + return this.aggregateByFile( + this.sortSnippetsChronologically(allSnippets), + projectPath, + includeDetails + ); + } + + private async fallbackSingleTaskScope( + teamName: string, + taskId: string, + logRefs: LogFileRef[], + projectPath?: string, + includeDetails = true + ): Promise { + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allSnippets = this.sortSnippetsChronologically( + allParsed.flatMap((result) => result.snippets) + ); + const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); + + return { + teamName, + taskId, + files: aggregatedFiles, + totalLinesAdded: aggregatedFiles.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: aggregatedFiles.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: aggregatedFiles.length, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: logRefs[0]?.memberName ?? 'unknown', + startLine: 1, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: aggregatedFiles.map((file) => file.filePath), + confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, + }, + warnings: ['No task boundaries found — showing all changes from related sessions.'], + }; + } + + private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { + return { + teamName, + taskId, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: ['No log files found for this task.'], + }; + } + + private async parseJSONLFilesWithConcurrency( + paths: string[] + ): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> { + if (paths.length === 0) return []; + + const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length); + let nextIndex = 0; + + const worker = async (): Promise => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= paths.length) return; + results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) }, + () => worker() + ) + ); + + return results; + } + + private async parseJSONLFile( + filePath: string + ): Promise<{ snippets: SnippetDiff[]; mtime: number }> { + let fileMtime = 0; + try { + const fileStat = await stat(filePath); + fileMtime = fileStat.mtimeMs; + const cached = this.parsedSnippetsCache.get(filePath); + if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) { + return { snippets: cached.data, mtime: fileMtime }; + } + } catch (error) { + logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`); + return { snippets: [], mtime: 0 }; + } + + const entries: Record[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as Record); + } catch { + // Ignore invalid JSON lines. + } + } + + rl.close(); + stream.destroy(); + } catch (error) { + logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`); + return { snippets: [], mtime: 0 }; + } + + const erroredIds = this.collectErroredToolUseIds(entries); + const snippets: SnippetDiff[] = []; + const seenFiles = new Set(); + + for (const entry of entries) { + const role = this.extractRole(entry); + if (role !== 'assistant') continue; + + const content = this.extractContent(entry); + if (!content) continue; + + const timestamp = + typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(); + + for (const block of content) { + if ( + !block || + typeof block !== 'object' || + (block as Record).type !== 'tool_use' + ) { + continue; + } + + const toolBlock = block as Record; + const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : ''; + const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName; + const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : ''; + const input = toolBlock.input as Record | undefined; + if (!input) continue; + + const isError = erroredIds.has(toolUseId); + + if (toolName === 'Edit') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const oldString = typeof input.old_string === 'string' ? input.old_string : ''; + const newString = typeof input.new_string === 'string' ? input.new_string : ''; + const replaceAll = input.replace_all === true; + + if (targetPath) { + seenFiles.add(this.normalizeFilePathKey(targetPath)); + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'Edit', + type: 'edit', + oldString, + newString, + replaceAll, + timestamp, + isError, + contextHash: this.computeContextHash(oldString, newString), + }); + } + } else if (toolName === 'Write') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const writeContent = typeof input.content === 'string' ? input.content : ''; + + if (targetPath) { + const normalizedTargetPath = this.normalizeFilePathKey(targetPath); + const isNew = !seenFiles.has(normalizedTargetPath); + seenFiles.add(normalizedTargetPath); + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'Write', + type: isNew ? 'write-new' : 'write-update', + oldString: '', + newString: writeContent, + replaceAll: false, + timestamp, + isError, + contextHash: this.computeContextHash('', writeContent), + }); + } + } else if (toolName === 'MultiEdit') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const edits = Array.isArray(input.edits) ? input.edits : []; + + if (targetPath) { + seenFiles.add(this.normalizeFilePathKey(targetPath)); + for (const edit of edits) { + if (!edit || typeof edit !== 'object') continue; + const editObj = edit as Record; + const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; + const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'MultiEdit', + type: 'multi-edit', + oldString, + newString, + replaceAll: false, + timestamp, + isError, + contextHash: this.computeContextHash(oldString, newString), + }); + } + } + } + } + } + + this.parsedSnippetsCache.set(filePath, { + data: snippets, + mtime: fileMtime, + expiresAt: Date.now() + this.parsedSnippetsCacheTtl, + }); + + return { snippets, mtime: fileMtime }; + } + + private extractContent(entry: Record): unknown[] | null { + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) return message.content as unknown[]; + if (Array.isArray(entry.content)) return entry.content as unknown[]; + return null; + } + + private extractRole(entry: Record): string | null { + if (typeof entry.role === 'string') return entry.role; + const message = entry.message as Record | undefined; + if (message && typeof message.role === 'string') return message.role; + return null; + } + + private collectErroredToolUseIds(entries: Record[]): Set { + const erroredIds = new Set(); + + for (const entry of entries) { + if (Array.isArray(entry.content)) { + for (const block of entry.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) { + for (const block of message.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + } + + return erroredIds; + } + + private isErroredToolResult(block: unknown): boolean { + if (!block || typeof block !== 'object') return false; + const obj = block as Record; + return obj.type === 'tool_result' && obj.is_error === true; + } + + private aggregateByFile( + snippets: SnippetDiff[], + projectPath?: string, + includeDetails = true + ): FileChangeSummary[] { + const fileMap = new Map< + string, + { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + >(); + + for (const snippet of snippets) { + if (snippet.isError) continue; + + const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath); + const existing = fileMap.get(normalizedFilePath); + if (existing) { + existing.snippets.push(snippet); + if (snippet.type === 'write-new') existing.isNewFile = true; + } else { + fileMap.set(normalizedFilePath, { + filePath: snippet.filePath, + snippets: [snippet], + isNewFile: snippet.type === 'write-new', + }); + } + } + + return [...fileMap.values()].map((data) => { + let totalAdded = 0; + let totalRemoved = 0; + for (const snippet of data.snippets) { + if (snippet.isError) continue; + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + totalAdded += added; + totalRemoved += removed; + } + + const normalizedFilePath = data.filePath.replace(/\\/g, '/'); + const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); + const relativePath = normalizedProjectPath + ? normalizedFilePath.startsWith(normalizedProjectPath + '/') + ? normalizedFilePath.slice(normalizedProjectPath.length + 1) + : normalizedFilePath.startsWith(normalizedProjectPath) + ? normalizedFilePath.slice(normalizedProjectPath.length) + : normalizedFilePath.split('/').slice(-3).join('/') + : normalizedFilePath.split('/').slice(-3).join('/'); + + return { + filePath: data.filePath, + relativePath, + snippets: includeDetails ? data.snippets : [], + linesAdded: totalAdded, + linesRemoved: totalRemoved, + isNewFile: data.isNewFile, + timeline: includeDetails ? this.buildTimeline(data.filePath, data.snippets) : undefined, + }; + }); + } + + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { + const events: FileEditEvent[] = snippets + .filter((snippet) => !snippet.isError) + .map((snippet, index) => { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + return { + toolUseId: snippet.toolUseId, + toolName: snippet.toolName as FileEditEvent['toolName'], + timestamp: snippet.timestamp, + summary: this.generateEditSummary(snippet), + linesAdded: added, + linesRemoved: removed, + snippetIndex: index, + }; + }); + + const timestamps = events + .map((event) => new Date(event.timestamp).getTime()) + .filter((timestamp) => !Number.isNaN(timestamp)); + const durationMs = + timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; + + return { filePath, events, durationMs }; + } + + private generateEditSummary(snippet: SnippetDiff): string { + switch (snippet.type) { + case 'write-new': + return 'Created new file'; + case 'write-update': + return 'Wrote full file content'; + case 'multi-edit': { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + const total = added + removed; + return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; + } + case 'edit': { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; + if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; + return `Changed ${removed} → ${added} lines`; + } + default: + return 'File modified'; + } + } + + private computeContextHash(oldString: string, newString: string): string { + const take3 = (value: string): string => { + const lines = value.split('\n'); + const head = lines.slice(0, 3).join('\n'); + const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; + return `${head}|${tail}`; + }; + + const raw = `${take3(oldString)}::${take3(newString)}`; + let hash = 5381; + for (let index = 0; index < raw.length; index++) { + hash = ((hash << 5) + hash + raw.charCodeAt(index)) | 0; + } + return (hash >>> 0).toString(36); + } + + private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] { + return snippets + .map((snippet, originalIndex) => ({ snippet, originalIndex })) + .sort((a, b) => { + const aMs = Date.parse(a.snippet.timestamp); + const bMs = Date.parse(b.snippet.timestamp); + const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER; + const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER; + if (safeA !== safeB) return safeA - safeB; + if (a.snippet.filePath !== b.snippet.filePath) { + return a.snippet.filePath.localeCompare(b.snippet.filePath); + } + if (a.snippet.toolUseId !== b.snippet.toolUseId) { + return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId); + } + return a.originalIndex - b.originalIndex; + }) + .map(({ snippet }) => snippet); + } + + private normalizeFilePathKey(filePath: string): string { + return normalizeTaskChangePresenceFilePath(filePath); + } +} diff --git a/src/main/services/team/TaskChangeWorkerClient.ts b/src/main/services/team/TaskChangeWorkerClient.ts new file mode 100644 index 00000000..bee769c5 --- /dev/null +++ b/src/main/services/team/TaskChangeWorkerClient.ts @@ -0,0 +1,267 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { createLogger } from '@shared/utils/logger'; + +import type { + ResolvedTaskChangeComputeInput, + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from './taskChangeWorkerTypes'; +import type { TaskChangeSetV2 } from '@shared/types'; + +const logger = createLogger('Service:TaskChangeWorkerClient'); +const DEFAULT_WORKER_CALL_TIMEOUT_MS = 30_000; + +interface WorkerLike { + on(event: 'message', listener: (msg: TaskChangeWorkerResponse) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'exit', listener: (code: number) => void): this; + postMessage(message: TaskChangeWorkerRequest): void; + terminate(): Promise; +} + +interface QueueEntry { + id: string; + request: TaskChangeWorkerRequest; + resolve: (value: TaskChangeSetV2) => void; + reject: (error: Error) => void; +} + +function makeId(): string { + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; +} + +function resolveWorkerPath(): string | null { + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + path.join(baseDir, 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.js'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + + return null; +} + +export class TaskChangeWorkerClient { + private worker: WorkerLike | null = null; + private terminatingWorker: WorkerLike | null = null; + private readonly workerPath: string | null; + private readonly workerFactory: (workerPath: string) => WorkerLike; + private readonly timeoutMs: number; + private readonly enabled: boolean; + private warnedUnavailable = false; + private activeRequestId: string | null = null; + private activeTimeout: ReturnType | null = null; + private terminatingForTimeoutRequestId: string | null = null; + private pending = new Map(); + private queue: QueueEntry[] = []; + + constructor(options?: { + workerPath?: string | null; + workerFactory?: (workerPath: string) => WorkerLike; + timeoutMs?: number; + enabled?: boolean; + }) { + this.workerPath = + options && 'workerPath' in options ? (options.workerPath ?? null) : resolveWorkerPath(); + this.workerFactory = options?.workerFactory ?? ((workerPath) => new Worker(workerPath)); + this.timeoutMs = options?.timeoutMs ?? DEFAULT_WORKER_CALL_TIMEOUT_MS; + this.enabled = options?.enabled ?? process.env.CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER !== '0'; + } + + isAvailable(): boolean { + if (!this.enabled) { + return false; + } + + if (!this.workerPath && !this.warnedUnavailable) { + this.warnedUnavailable = true; + logger.warn('task-change-worker not found; falling back to main-thread extraction.'); + } + + return this.workerPath !== null; + } + + async computeTaskChanges(payload: ResolvedTaskChangeComputeInput): Promise { + if (!this.isAvailable()) { + throw new Error('Task change worker is not available in this environment'); + } + + const id = makeId(); + const entry: QueueEntry = { + id, + request: { id, op: 'computeTaskChanges', payload }, + resolve: () => undefined, + reject: () => undefined, + }; + + return new Promise((resolve, reject) => { + entry.resolve = resolve; + entry.reject = reject; + this.pending.set(id, entry); + this.queue.push(entry); + this.processQueue(); + }); + } + + private ensureWorker(): WorkerLike { + if (!this.workerPath) { + throw new Error('Task change worker is not available in this environment'); + } + if (this.worker) { + return this.worker; + } + + const worker = this.workerFactory(this.workerPath); + worker.on('message', (msg) => this.handleMessage(msg)); + worker.on('error', (error) => this.handleWorkerFailure(worker, error)); + worker.on('exit', (code) => this.handleWorkerExit(worker, code)); + this.worker = worker; + return worker; + } + + private processQueue(): void { + if (this.activeRequestId || this.queue.length === 0) { + return; + } + + const entry = this.queue.shift(); + if (!entry) { + return; + } + + const worker = this.ensureWorker(); + this.activeRequestId = entry.id; + this.activeTimeout = setTimeout(() => { + const activeId = this.activeRequestId; + if (!activeId) { + return; + } + + this.clearActiveState(); + this.terminatingForTimeoutRequestId = activeId; + const pending = this.pending.get(activeId); + if (pending) { + this.pending.delete(activeId); + pending.reject( + new Error(`Worker call timeout after ${this.timeoutMs}ms (computeTaskChanges)`) + ); + } + + try { + const workerToTerminate = this.worker; + this.terminatingWorker = workerToTerminate; + workerToTerminate?.terminate().catch(() => undefined); + } catch { + // ignore + } finally { + this.worker = null; + } + + this.processQueue(); + }, this.timeoutMs); + + try { + worker.postMessage(entry.request); + } catch (error) { + this.clearActiveState(); + this.pending.delete(entry.id); + entry.reject(error instanceof Error ? error : new Error(String(error))); + this.processQueue(); + } + } + + private handleMessage(message: TaskChangeWorkerResponse): void { + const entry = this.pending.get(message.id); + if (!entry) { + return; + } + + this.pending.delete(message.id); + if (this.activeRequestId === message.id) { + this.clearActiveState(); + } + + if (message.ok) { + entry.resolve(message.result); + } else { + entry.reject(new Error(message.error)); + } + + this.processQueue(); + } + + private handleWorkerFailure(worker: WorkerLike, error: Error): void { + logger.error('Task change worker error', error); + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + this.rejectAllPending(error); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private handleWorkerExit(worker: WorkerLike, code: number): void { + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + if (code !== 0) { + logger.warn(`Task change worker exited with code ${code}`); + } + this.rejectAllPending(new Error(`Worker exited with code ${code}`)); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private rejectAllPending(error: Error): void { + for (const entry of this.pending.values()) { + entry.reject(error); + } + this.pending.clear(); + this.queue = []; + } + + private clearActiveState(): void { + this.activeRequestId = null; + if (this.activeTimeout) { + clearTimeout(this.activeTimeout); + this.activeTimeout = null; + } + } +} + +let singleton: TaskChangeWorkerClient | null = null; + +export function getTaskChangeWorkerClient(): TaskChangeWorkerClient { + if (!singleton) { + singleton = new TaskChangeWorkerClient(); + } + return singleton; +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0070ae59..92bdbf5e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,11 +12,13 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; @@ -28,6 +30,8 @@ import * as path from 'path'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { atomicWriteAsync } from './atomicWrite'; +import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; @@ -40,6 +44,9 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { AddMemberRequest, AttachmentMeta, @@ -52,6 +59,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TaskRef, TeamConfig, @@ -90,6 +98,11 @@ interface EligibleTaskCommentNotification { summary: string; } +interface TaskChangeLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + export class TeamDataService { private processHealthTimer: ReturnType | null = null; private processHealthTeams = new Set(); @@ -97,6 +110,8 @@ export class TeamDataService { private notifiedTaskStarts = new Set(); private taskCommentNotificationInitialization: Promise | null = null; private taskCommentNotificationInFlight = new Set(); + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -167,6 +182,161 @@ export class TeamDataService { return null; } + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } + + setTaskChangePresenceTracking(teamName: string, enabled: boolean): void { + if (!this.teamLogSourceTracker) { + return; + } + + if (enabled) { + void this.teamLogSourceTracker + .enableTracking(teamName, 'change_presence') + .catch((error) => + logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`) + ); + return; + } + + void this.teamLogSourceTracker + .disableTracking(teamName, 'change_presence') + .catch((error) => + logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`) + ); + } + + private resolveTaskChangePresenceMap( + tasks: readonly TeamTaskWithKanban[], + changePresenceEnabled: boolean, + presenceIndex: PersistedTaskChangePresenceIndex | null, + logSourceSnapshot: TaskChangeLogSourceSnapshot | null + ): Record { + const result: Record = {}; + if ( + !changePresenceEnabled || + !presenceIndex || + !logSourceSnapshot?.projectFingerprint || + !logSourceSnapshot.logSourceGeneration || + presenceIndex.projectFingerprint !== logSourceSnapshot.projectFingerprint || + presenceIndex.logSourceGeneration !== logSourceSnapshot.logSourceGeneration + ) { + for (const task of tasks) { + result[task.id] = 'unknown'; + } + return result; + } + + for (const task of tasks) { + const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: task.createdAt, + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + reviewState: task.reviewState, + historyEvents: task.historyEvents, + kanbanColumn: task.kanbanColumn, + }); + const presenceEntry = presenceIndex.entries[task.id]; + result[task.id] = + presenceEntry?.taskSignature === descriptor.taskSignature && + presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration + ? presenceEntry.presence + : 'unknown'; + } + + return result; + } + + private isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { + if (typeof message.to === 'string' && message.to.trim().length > 0) return false; + if (message.from === 'system') return false; + return message.source === 'lead_session' || message.source === 'lead_process'; + } + + private annotateSlashCommandResponses(messages: InboxMessage[]): void { + let pendingSlash = null as InboxMessage['slashCommand'] | null; + + for (const message of messages) { + const slashCommand = + message.source === 'user_sent' + ? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text)) + : null; + + if (slashCommand) { + pendingSlash = slashCommand; + continue; + } + + if (!pendingSlash) { + continue; + } + + if (message.messageKind === 'slash_command_result') { + continue; + } + + if (this.isLeadThoughtCandidateForSlashResult(message)) { + message.messageKind = 'slash_command_result'; + message.commandOutput = { + stream: 'stdout', + commandLabel: pendingSlash.command, + }; + continue; + } + + pendingSlash = null; + } + } + + async getTaskChangePresence(teamName: string): Promise> { + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team not found: ${teamName}`); + } + + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + + const [tasks, kanbanState, presenceIndex] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), + this.kanbanManager + .getState(teamName) + .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null), + ]); + + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => + this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) + ); + + return this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + } + async listTeams(): Promise { return this.configReader.listTeams(); } @@ -332,6 +502,24 @@ export class TeamDataService { mark('config'); const warnings: string[] = []; + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + const presenceIndexPromise = + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null); let tasks: TeamTask[] = []; try { @@ -448,6 +636,9 @@ export class TeamDataService { } } + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + this.annotateSlashCommandResponses(messages); + messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); let metaMembers: TeamConfig['members'] = []; @@ -472,10 +663,25 @@ export class TeamDataService { mark('kanbanGc'); - const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) ); + const presenceIndex = await presenceIndexPromise; + + const taskChangePresenceById = this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + const tasksWithKanban: TeamTaskWithKanban[] = changePresenceEnabled + ? tasksWithKanbanBase.map((task) => ({ + ...task, + changePresence: taskChangePresenceById[task.id] ?? 'unknown', + })) + : tasksWithKanbanBase; + const members = this.memberResolver.resolveMembers( config, metaMembers, @@ -491,10 +697,6 @@ export class TeamDataService { mark('syncComments'); - const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) - ); - let processes: TeamProcess[] = []; try { processes = await this.readProcesses(teamName); @@ -529,7 +731,7 @@ export class TeamDataService { return { teamName, config, - tasks: tasksToReturn, + tasks: tasksWithKanban, members, messages, kanbanState, @@ -876,6 +1078,19 @@ export class TeamDataService { ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; + // Controller's maybeNotifyAssignedOwner skips the lead (owner === lead). + // For user-created tasks with startImmediately, ensure the lead also gets notified. + if (shouldStart) { + try { + const leadName = await this.resolveLeadName(teamName); + if (this.isLeadOwner(task.owner!, leadName)) { + await this.sendUserTaskStartNotification(teamName, task); + } + } catch { + /* best-effort */ + } + } + return task; } @@ -927,6 +1142,69 @@ export class TeamDataService { return { notifiedOwner: !!task.owner }; } + /** + * Start a task triggered by the user via UI. + * Unlike startTask(), this always notifies the owner (including the lead in solo teams). + */ + async startTaskByUser(teamName: string, taskId: string): Promise<{ notifiedOwner: boolean }> { + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((t) => t.id === taskId); + if (!task) { + throw new Error(`Task #${taskId} not found`); + } + if (task.status !== 'pending') { + throw new Error(`Task #${taskId} is not pending (current: ${task.status})`); + } + + this.getController(teamName).tasks.startTask(taskId, 'user'); + + if (task.owner) { + await this.sendUserTaskStartNotification(teamName, task); + } + + return { notifiedOwner: !!task.owner }; + } + + /** + * Send a task start notification from the user to the task owner. + * Includes description, prompt, and task_get/task_complete instructions. + * Used by startTaskByUser and createTask (startImmediately). + */ + private async sendUserTaskStartNotification(teamName: string, task: TeamTask): Promise { + if (!task.owner) return; + try { + const parts = [`**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`]; + if (task.description?.trim()) { + parts.push(`\nDetails:\n${task.description.trim()}`); + } + if (task.prompt?.trim()) { + parts.push(`\nInstructions:\n${task.prompt.trim()}`); + } + parts.push( + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `To fetch the full task context (description, comments, attachments) use:`, + `task_get { teamName: "${teamName}", taskId: "${task.id}" }`, + `When done, update task status:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) + ); + await this.sendMessage(teamName, { + member: task.owner, + from: 'user', + text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, + summary: `Start working on ${this.getTaskLabel(task)}`, + source: 'system_notification', + }); + } catch { + // Best-effort notification + } + } + async updateTaskStatus( teamName: string, taskId: string, @@ -1110,6 +1388,15 @@ export class TeamDataService { // non-critical } } + const slashCommandMeta = + enrichedRequest.slashCommand ?? buildStandaloneSlashCommandMeta(enrichedRequest.text); + if (slashCommandMeta) { + enrichedRequest = { + ...enrichedRequest, + messageKind: 'slash_command', + slashCommand: slashCommandMeta, + }; + } return this.getController(teamName).messages.sendMessage({ member: enrichedRequest.member, from: enrichedRequest.from, @@ -1122,6 +1409,9 @@ export class TeamDataService { replyToConversationId: enrichedRequest.replyToConversationId, toolSummary: enrichedRequest.toolSummary, toolCalls: enrichedRequest.toolCalls, + messageKind: enrichedRequest.messageKind, + slashCommand: enrichedRequest.slashCommand, + commandOutput: enrichedRequest.commandOutput, taskRefs: enrichedRequest.taskRefs, summary: enrichedRequest.summary, source: enrichedRequest.source, @@ -1517,6 +1807,7 @@ export class TeamDataService { text: notification.text, summary: notification.summary, source: TASK_COMMENT_NOTIFICATION_SOURCE, + messageKind: 'task_comment_notification', leadSessionId: notification.leadSessionId, taskRefs: [notification.taskRef], messageId: notification.messageId, @@ -1550,6 +1841,7 @@ export class TeamDataService { // non-critical — proceed without sessionId } + const slashCommandMeta = buildStandaloneSlashCommandMeta(text); const msg = this.getController(teamName).messages.appendSentMessage({ from: 'user', to: leadName, @@ -1559,6 +1851,12 @@ export class TeamDataService { source: 'user_sent', attachments: attachments?.length ? attachments : undefined, leadSessionId, + ...(slashCommandMeta + ? { + messageKind: 'slash_command', + slashCommand: slashCommandMeta, + } + : {}), ...(messageId ? { messageId } : {}), }) as InboxMessage; return { @@ -1729,7 +2027,7 @@ export class TeamDataService { return sessionIds; } - private async extractLeadSessionTextsFromJsonl( + private async extractLeadAssistantTextsFromJsonl( jsonlPath: string, leadName: string, leadSessionId: string, @@ -1737,10 +2035,8 @@ export class TeamDataService { ): Promise { if (maxTexts <= 0) return []; - // Optimization: read from the end of the JSONL file (we only need the last N texts). - // The full file can be huge; scanning from the start causes long stalls on Windows. - const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap - const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB + const MAX_SCAN_BYTES = 8 * 1024 * 1024; + const INITIAL_SCAN_BYTES = 256 * 1024; const textsReversed: InboxMessage[] = []; const seenMessageIds = new Set(); @@ -1757,7 +2053,6 @@ export class TeamDataService { const chunk = buffer.toString('utf8'); const lines = chunk.split(/\r?\n/); - // If we started mid-file, the first line may be partial — drop it. const fromIndex = start > 0 ? 1 : 0; for (let i = lines.length - 1; i >= fromIndex; i--) { @@ -1790,8 +2085,6 @@ export class TeamDataService { 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++) { @@ -1803,12 +2096,12 @@ export class TeamDataService { } catch { continue; } - if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines + if (tMsg.type !== 'assistant') continue; 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 + if (tBlocks.some((b) => b.type === 'text')) break; for (const b of tBlocks) { if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') { const input = (b.input ?? {}) as Record; @@ -1830,7 +2123,6 @@ export class TeamDataService { ? `lead-thought-msg-${assistantMessageId}` : null; - // Fallback messageId: timestamp + text prefix (survives tail-scan range changes) const textPrefix = combined .slice(0, 50) .replace(/[^\p{L}\p{N}]/gu, '') @@ -1863,10 +2155,29 @@ export class TeamDataService { await handle.close(); } - // Convert back to chronological order (old behavior) and keep the last N texts. textsReversed.reverse(); - const texts = textsReversed; - return texts.length > maxTexts ? texts.slice(-maxTexts) : texts; + return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed; + } + + private async extractLeadSessionTextsFromJsonl( + jsonlPath: string, + leadName: string, + leadSessionId: string, + maxTexts: number + ): Promise { + const [assistantTexts, commandResults] = await Promise.all([ + this.extractLeadAssistantTextsFromJsonl(jsonlPath, leadName, leadSessionId, maxTexts), + extractLeadSessionMessagesFromJsonl({ + jsonlPath, + leadName, + leadSessionId, + maxMessages: maxTexts, + }), + ]); + + const combined = [...assistantTexts, ...commandResults]; + combined.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return combined.length > maxTexts ? combined.slice(-maxTexts) : combined; } private async extractLeadSessionTexts(config: TeamConfig): Promise { diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 11e5f1f9..563a634c 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -131,6 +131,39 @@ export class TeamInboxReader { preview: typeof tc.preview === 'string' ? tc.preview : undefined, })) : undefined, + messageKind: + row.messageKind === 'slash_command' || + row.messageKind === 'slash_command_result' || + row.messageKind === 'task_comment_notification' + ? row.messageKind + : row.messageKind === 'default' + ? 'default' + : undefined, + slashCommand: + row.slashCommand && + typeof row.slashCommand === 'object' && + typeof row.slashCommand.name === 'string' && + typeof row.slashCommand.command === 'string' + ? { + name: row.slashCommand.name, + command: row.slashCommand.command, + args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined, + knownDescription: + typeof row.slashCommand.knownDescription === 'string' + ? row.slashCommand.knownDescription + : undefined, + } + : undefined, + commandOutput: + row.commandOutput && + typeof row.commandOutput === 'object' && + (row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') && + typeof row.commandOutput.commandLabel === 'string' + ? { + stream: row.commandOutput.stream, + commandLabel: row.commandOutput.commandLabel, + } + : undefined, }); } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 006ef14d..272f4d45 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -41,6 +41,9 @@ export class TeamInboxWriter { }), ...(request.toolSummary && { toolSummary: request.toolSummary }), ...(request.toolCalls && { toolCalls: request.toolCalls }), + ...(request.messageKind && { messageKind: request.messageKind }), + ...(request.slashCommand && { slashCommand: request.slashCommand }), + ...(request.commandOutput && { commandOutput: request.commandOutput }), }; await withFileLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts new file mode 100644 index 00000000..1c5ee933 --- /dev/null +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -0,0 +1,398 @@ +import { createLogger } from '@shared/utils/logger'; +import { watch } from 'chokidar'; +import { createHash } from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; + +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { TeamChangeEvent } from '@shared/types'; +import type { FSWatcher } from 'chokidar'; + +const logger = createLogger('Service:TeamLogSourceTracker'); + +interface TeamLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + +export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity'; + +interface TrackingState { + watcher: FSWatcher | null; + projectDir: string | null; + refreshTimer: ReturnType | null; + initializePromise: Promise | null; + initializeVersion: number | null; + recomputePromise: Promise | null; + recomputeVersion: number | null; + snapshot: TeamLogSourceSnapshot; + consumers: Set; + lifecycleVersion: number; +} + +export class TeamLogSourceTracker { + private readonly stateByTeam = new Map(); + private emitter: ((event: TeamChangeEvent) => void) | null = null; + private readonly changeListeners = new Set<(teamName: string) => void>(); + + constructor(private readonly logsFinder: TeamMemberLogsFinder) {} + + setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { + this.emitter = emitter; + } + + onLogSourceChange(listener: (teamName: string) => void): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + + getSnapshot(teamName: string): TeamLogSourceSnapshot | null { + const state = this.stateByTeam.get(teamName); + return state ? { ...state.snapshot } : null; + } + + async ensureTracking(teamName: string): Promise { + return this.enableTracking(teamName, 'change_presence'); + } + + async enableTracking( + teamName: string, + consumer: TeamLogSourceTrackingConsumer + ): Promise { + const state = this.getOrCreateState(teamName); + if (!state.consumers.has(consumer)) { + state.consumers.add(consumer); + state.lifecycleVersion += 1; + } + + if ( + state.initializePromise && + state.initializeVersion === state.lifecycleVersion && + state.consumers.size > 0 + ) { + return state.initializePromise; + } + + const initializeVersion = state.lifecycleVersion; + const initializePromise = this.initializeTeam(teamName, initializeVersion) + .catch((error) => { + logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`); + return { projectFingerprint: null, logSourceGeneration: null }; + }) + .finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.initializePromise === initializePromise) { + current.initializePromise = null; + current.initializeVersion = null; + } + }); + + state.initializePromise = initializePromise; + state.initializeVersion = initializeVersion; + return initializePromise; + } + + async dispose(): Promise { + await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName))); + } + + private getOrCreateState(teamName: string): TrackingState { + const existing = this.stateByTeam.get(teamName); + if (existing) { + return existing; + } + + const created: TrackingState = { + watcher: null, + projectDir: null, + refreshTimer: null, + initializePromise: null, + initializeVersion: null, + recomputePromise: null, + recomputeVersion: null, + snapshot: { projectFingerprint: null, logSourceGeneration: null }, + consumers: new Set(), + lifecycleVersion: 0, + }; + this.stateByTeam.set(teamName, created); + return created; + } + + async stopTracking(teamName: string): Promise { + await this.disableTracking(teamName, 'change_presence'); + } + + async disableTracking( + teamName: string, + consumer: TeamLogSourceTrackingConsumer + ): Promise { + const state = this.stateByTeam.get(teamName); + if (!state) { + return { projectFingerprint: null, logSourceGeneration: null }; + } + + if (state.consumers.has(consumer)) { + state.consumers.delete(consumer); + state.lifecycleVersion += 1; + } + + if (state.consumers.size > 0) { + return { ...state.snapshot }; + } + + if (state.refreshTimer) { + clearTimeout(state.refreshTimer); + state.refreshTimer = null; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = null; + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + return { ...state.snapshot }; + } + + private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { + const state = this.stateByTeam.get(teamName); + return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion; + } + + private async initializeTeam( + teamName: string, + expectedVersion: number + ): Promise { + const state = this.getOrCreateState(teamName); + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, expectedVersion); + return state.snapshot; + } + + const snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + state.snapshot = snapshot; + await this.rebuildWatcher(teamName, context.projectDir, expectedVersion); + if ( + this.isTrackingCurrent(teamName, expectedVersion) && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitLogSourceChange(teamName); + } + return snapshot; + } + + private async rebuildWatcher( + teamName: string, + projectDir: string | null, + expectedVersion: number + ): Promise { + const state = this.stateByTeam.get(teamName); + if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) { + return; + } + if (state.projectDir === projectDir && state.watcher) { + return; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = projectDir; + if (!projectDir) { + return; + } + + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + state.projectDir = null; + return; + } + + state.watcher = watch(projectDir, { + ignoreInitial: true, + ignorePermissionErrors: true, + followSymlinks: false, + depth: 3, + awaitWriteFinish: { + stabilityThreshold: 250, + pollInterval: 50, + }, + }); + + const scheduleRecompute = (): void => { + const current = this.stateByTeam.get(teamName); + if (!current || current.consumers.size === 0) { + return; + } + if (current.refreshTimer) { + clearTimeout(current.refreshTimer); + } + current.refreshTimer = setTimeout(() => { + current.refreshTimer = null; + void this.recompute(teamName); + }, 300); + }; + + state.watcher.on('add', scheduleRecompute); + state.watcher.on('change', scheduleRecompute); + state.watcher.on('unlink', scheduleRecompute); + state.watcher.on('addDir', scheduleRecompute); + state.watcher.on('unlinkDir', scheduleRecompute); + state.watcher.on('error', (error) => { + logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`); + }); + } + + private async recompute(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (state.consumers.size === 0) { + return state.snapshot; + } + if ( + state.recomputePromise && + state.recomputeVersion === state.lifecycleVersion && + state.consumers.size > 0 + ) { + return state.recomputePromise; + } + + const recomputeVersion = state.lifecycleVersion; + const recomputePromise = (async () => { + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, recomputeVersion); + } else { + state.snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion); + } + + if ( + this.isTrackingCurrent(teamName, recomputeVersion) && + previousGeneration && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitLogSourceChange(teamName); + } + + return state.snapshot; + })().finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.recomputePromise === recomputePromise) { + current.recomputePromise = null; + current.recomputeVersion = null; + } + }); + + state.recomputePromise = recomputePromise; + state.recomputeVersion = recomputeVersion; + return recomputePromise; + } + + private emitLogSourceChange(teamName: string): void { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + for (const listener of this.changeListeners) { + try { + listener(teamName); + } catch (error) { + logger.warn(`Log-source listener failed for ${teamName}: ${String(error)}`); + } + } + } + + private async computeSnapshot(context: { + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + }): Promise { + const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath); + const parts: string[] = []; + + if (context.leadSessionId) { + const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`); + parts.push(await this.describePath('lead', leadLogPath)); + } + + for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) { + const sessionDir = path.join(context.projectDir, sessionId); + const subagentsDir = path.join(sessionDir, 'subagents'); + parts.push(await this.describePath('session', sessionDir)); + parts.push(await this.describePath('subagents', subagentsDir)); + + let entries: string[] = []; + try { + entries = await fs.readdir(subagentsDir); + } catch { + entries = []; + } + + for (const fileName of entries + .filter( + (entry) => + entry.startsWith('agent-') && + entry.endsWith('.jsonl') && + !entry.startsWith('agent-acompact') + ) + .sort((a, b) => a.localeCompare(b))) { + parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName))); + } + } + + const sourceMaterial = + parts.length > 0 + ? parts.join('|') + : `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`; + + return { + projectFingerprint, + logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'), + }; + } + + private async describePath(kind: string, targetPath: string): Promise { + const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath); + try { + const stats = await fs.stat(targetPath); + const type = stats.isDirectory() ? 'dir' : 'file'; + return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`; + } catch { + return `${kind}:missing:${normalizedPath}`; + } + } +} diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 85947e31..2b96a824 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,9 +1,8 @@ -import { getHomeDir } from '@main/utils/pathDecoder'; +import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; @@ -15,14 +14,17 @@ interface McpLaunchSpec { const MCP_SERVER_NAME = 'agent-teams'; const logger = createLogger('Service:TeamMcpConfigBuilder'); -const USER_MCP_CONFIG_NAME = '.claude.json'; +const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; +/** + * Stale configs older than this are removed on startup (best-effort). + * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved + * --mcp-config paths, so shorter TTLs risk deleting configs still needed + * by long-running or retrying sessions in other app instances. + */ +const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - function isPackagedApp(): boolean { try { const { app } = require('electron') as typeof import('electron'); @@ -32,10 +34,20 @@ function isPackagedApp(): boolean { } } +function getAppVersion(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { app } = require('electron') as typeof import('electron'); + return app.getVersion(); + } catch { + return '0.0.0-dev'; + } +} + /** * In a packaged Electron build the mcp-server bundle lives under * `process.resourcesPath/mcp-server/index.js` (copied via extraResources). - * In dev mode we resolve relative to the workspace root (process.cwd()). + * This is the fallback location when the stable copy is unavailable. */ function getPackagedServerEntry(): string { return path.join(process.resourcesPath, 'mcp-server', 'index.js'); @@ -45,16 +57,16 @@ function getWorkspaceRoot(): string { return process.cwd(); } -function getMcpServerDir(): string { +function getWorkspaceMcpServerDir(): string { return path.join(getWorkspaceRoot(), 'mcp-server'); } function getBuiltServerEntry(): string { - return path.join(getMcpServerDir(), 'dist', 'index.js'); + return path.join(getWorkspaceMcpServerDir(), 'dist', 'index.js'); } function getSourceServerEntry(): string { - return path.join(getMcpServerDir(), 'src', 'index.ts'); + return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } async function pathExists(targetPath: string): Promise { @@ -66,6 +78,14 @@ async function pathExists(targetPath: string): Promise { } } +/** Check that both index.js and package.json exist in a directory. */ +async function hasValidServerCopy(dir: string): Promise { + return ( + (await pathExists(path.join(dir, 'index.js'))) && + (await pathExists(path.join(dir, 'package.json'))) + ); +} + let _resolvedNodePath: string | undefined; /** @@ -99,12 +119,88 @@ async function resolveNodePath(): Promise { return _resolvedNodePath; } +/** + * For packaged builds, copy the MCP server to a stable, writable location + * under userData so the server runs from a non-FUSE path (fixes AppImage). + * + * Uses a versioned subdirectory + atomic rename to avoid partial state: + * userData/mcp-server//index.js + * userData/mcp-server//package.json + * + * Returns the resolved index.js path (stable copy or resourcesPath fallback). + */ +async function resolvePackagedServerEntry(): Promise { + const fallbackEntry = getPackagedServerEntry(); + if (!isPackagedApp()) return fallbackEntry; + + const appVersion = getAppVersion(); + const baseDir = getMcpServerBasePath(); + const finalDir = path.join(baseDir, appVersion); + const finalEntry = path.join(finalDir, 'index.js'); + + // Reuse existing valid copy + if (await hasValidServerCopy(finalDir)) { + return finalEntry; + } + + // Heal invalid finalDir (partial state from previous crash) + try { + if ((await pathExists(finalDir)) && !(await hasValidServerCopy(finalDir))) { + logger.warn(`Removing invalid MCP server copy at ${finalDir}`); + await fs.promises.rm(finalDir, { recursive: true, force: true }); + } + } catch { + /* best-effort heal */ + } + + try { + const sourceDir = path.join(process.resourcesPath, 'mcp-server'); + if (!(await hasValidServerCopy(sourceDir))) { + logger.warn(`Packaged MCP server missing in resourcesPath: ${sourceDir}`); + return fallbackEntry; + } + + // Atomic: copy to temp dir, then rename to final + const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`); + await fs.promises.mkdir(tmpDir, { recursive: true }); + await fs.promises.copyFile(path.join(sourceDir, 'index.js'), path.join(tmpDir, 'index.js')); + await fs.promises.copyFile( + path.join(sourceDir, 'package.json'), + path.join(tmpDir, 'package.json') + ); + + try { + await fs.promises.rename(tmpDir, finalDir); + } catch { + // finalDir appeared between our check and rename (another process won the race) + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + if (await hasValidServerCopy(finalDir)) { + logger.info(`Using stable MCP server copy at ${finalDir} (concurrent copy resolved)`); + return finalEntry; + } + // Neither our copy nor the winner's copy is valid — fallback + logger.warn(`Concurrent MCP server copy failed, using resourcesPath fallback`); + return fallbackEntry; + } + + logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`); + return finalEntry; + } catch (error) { + logger.warn( + `Failed to copy MCP server to stable path, using resourcesPath fallback: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return fallbackEntry; + } +} + async function resolveMcpLaunchSpec(): Promise { const checked: string[] = []; - // 1. Packaged Electron app — use extraResources bundle + // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath if (isPackagedApp()) { - const packagedEntry = getPackagedServerEntry(); + const packagedEntry = await resolvePackagedServerEntry(); checked.push(packagedEntry); if (await pathExists(packagedEntry)) { return { @@ -121,7 +217,7 @@ async function resolveMcpLaunchSpec(): Promise { if (await pathExists(sourceEntry)) { return { command: 'pnpm', - args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry], + args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry], }; } @@ -143,23 +239,28 @@ async function resolveMcpLaunchSpec(): Promise { export class TeamMcpConfigBuilder { async writeConfigFile(_projectPath?: string): Promise { const launchSpec = await resolveMcpLaunchSpec(); - const configDir = path.join(os.tmpdir(), 'claude-team-mcp'); - const configPath = path.join(configDir, `agent-teams-mcp-${randomUUID()}.json`); - const userServers = await this.readUserMcpServers(); + const configDir = getMcpConfigsBasePath(); + const configPath = path.join( + configDir, + `${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json` + ); + // Keep the team bootstrap config minimal: recent Claude sidechain runs can + // lose the agent-teams tool surface when we inline large user MCP bundles + // into the generated --mcp-config. User/project/local MCP remain loaded + // through Claude's native settings sources. const generatedServers: Record = { [MCP_SERVER_NAME]: { command: launchSpec.command, args: launchSpec.args, }, }; - const mergedServers = this.mergeServers(userServers, generatedServers); await fs.promises.mkdir(configDir, { recursive: true }); await atomicWriteAsync( configPath, JSON.stringify( { - mcpServers: mergedServers, + mcpServers: generatedServers, }, null, 2 @@ -169,60 +270,66 @@ export class TeamMcpConfigBuilder { return configPath; } - private async readUserMcpServers(): Promise> { - const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME); - return this.readMcpServersFromFile(configPath, 'user'); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' - ): Promise> { + /** Delete a single MCP config file (best-effort). */ + async removeConfigFile(configPath: string): Promise { try { - const raw = await fs.promises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw) as Record; - const mcpServers = parsed.mcpServers; - if (!isRecord(mcpServers)) { - return {}; - } - - return Object.fromEntries( - Object.entries(mcpServers).filter(([, config]) => isRecord(config)) - ) as Record; + await fs.promises.unlink(configPath); } catch (error) { const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - return {}; + if (err.code !== 'ENOENT') { + logger.warn(`Failed to remove MCP config ${configPath}: ${err.message}`); } - - logger.warn( - `Failed to read ${scope} MCP config from ${filePath}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - return {}; } } - private mergeServers( - userServers: Record, - generatedServers: Record - ): Record { - const duplicates = Object.keys(userServers).filter((name) => - Object.hasOwn(generatedServers, name) - ); - - if (duplicates.length > 0) { - logger.info(`Merging MCP configs with overrides for: ${duplicates.join(', ')}`); + /** Remove config files owned by current process (shutdown best-effort). */ + async gcOwnConfigs(): Promise { + const configDir = getMcpConfigsBasePath(); + const ownPrefix = `${MCP_CONFIG_PREFIX}${process.pid}-`; + try { + const entries = await fs.promises.readdir(configDir); + await Promise.all( + entries + .filter((n) => n.startsWith(ownPrefix) && n.endsWith('.json')) + .map((n) => fs.promises.unlink(path.join(configDir, n)).catch(() => {})) + ); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + logger.warn(`Failed to GC own MCP configs: ${err.message}`); + } } + } - // We inline only top-level user MCP into --mcp-config. - // Project/local scopes are still loaded natively by Claude via - // --setting-sources user,project,local, which preserves documented precedence: - // local > project > user. Generated agent-teams must always win on name collision. - return { - ...userServers, - ...generatedServers, - }; + /** + * Remove stale config files older than maxAgeMs (startup GC, best-effort). + * Risk is reduced but not eliminated for multi-instance scenarios: + * respawnAfterAuthFailure() has its own recovery to regenerate deleted configs. + */ + async gcStaleConfigs(maxAgeMs = MCP_CONFIG_STALE_MAX_AGE_MS): Promise { + const configDir = getMcpConfigsBasePath(); + try { + const entries = await fs.promises.readdir(configDir); + await Promise.all( + entries + .filter((n) => n.startsWith(MCP_CONFIG_PREFIX) && n.endsWith('.json')) + .map(async (n) => { + const fullPath = path.join(configDir, n); + try { + const stat = await fs.promises.stat(fullPath); + if (Date.now() - stat.mtimeMs > maxAgeMs) { + await fs.promises.unlink(fullPath); + } + } catch { + /* ignore per-file errors */ + } + }) + ); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + logger.warn(`Failed to GC stale MCP configs: ${err.message}`); + } + } } } diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6bdfa8d5..ed3eea68 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -25,6 +25,7 @@ const ATTRIBUTION_SCAN_LINES = 50; /** Grace before task creation — logs cannot reference a task before it exists. */ const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; const FILE_MENTIONS_CACHE_MAX = 10_000; +const ATTRIBUTION_CACHE_MAX = 5_000; /** Max concurrent file reads during parallel scan phases. */ const SCAN_CONCURRENCY = 15; @@ -87,6 +88,7 @@ function trimTrailingSlashes(value: string): string { export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); + private readonly attributionCache = new Map(); private readonly discoveryCache = new Map< string, { @@ -173,6 +175,32 @@ export class TeamMemberLogsFinder { ); } + async getLogSourceWatchContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + } | null> { + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } + + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) { + return null; + } + + return { + projectDir: discovery.projectDir, + projectPath: discovery.config.projectPath, + leadSessionId: discovery.config.leadSessionId, + sessionIds: [...discovery.sessionIds], + }; + } + /** * Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.). * When the task is in_progress and has an owner, also includes that owner's session logs so @@ -634,7 +662,17 @@ export class TeamMemberLogsFinder { const filePath = path.join(subagentsDir, file); // Quick attribution check — only Phase 1 (no full-file streaming) - const attribution = await this.attributeSubagent(filePath, knownMembers); + let mtimeMs = 0; + try { + mtimeMs = (await fs.stat(filePath)).mtimeMs; + } catch { + continue; + } + const attribution = await this.getCachedSubagentAttribution( + filePath, + knownMembers, + mtimeMs + ); if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { paths.push(filePath); } @@ -644,6 +682,60 @@ export class TeamMemberLogsFinder { return paths; } + async listAttributedSubagentFiles( + teamName: string + ): Promise> { + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) return []; + + const { projectDir, sessionIds, knownMembers, config } = discovery; + const currentLeadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + // Live teammate tool tracking should follow the current team run, not historical + // lead sessions kept in sessionHistory or lingering on disk. + const candidateSessionIds = + currentLeadSessionId && sessionIds.includes(currentLeadSessionId) + ? [currentLeadSessionId] + : sessionIds; + const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); + const results: Array<{ + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + }> = []; + + const settled = await Promise.all( + candidates.map(async (candidate) => { + try { + const stat = await fs.stat(candidate.filePath); + const attribution = await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ); + if (!attribution) return null; + return { + memberName: attribution.detectedMember, + sessionId: candidate.sessionId, + filePath: candidate.filePath, + mtimeMs: stat.mtimeMs, + }; + } catch { + return null; + } + }) + ); + + for (const item of settled) { + if (item) results.push(item); + } + + return results; + } + /** * Fast marker probe for task-related logs. * Prefer structured MCP/TaskUpdate markers for modern sessions. @@ -1124,6 +1216,24 @@ export class TeamMemberLogsFinder { } } + private async getCachedSubagentAttribution( + filePath: string, + knownMembers: Set, + mtimeMs: number + ): Promise { + const cacheKey = `${filePath}:${mtimeMs}`; + if (this.attributionCache.has(cacheKey)) { + return this.attributionCache.get(cacheKey) ?? null; + } + const attribution = await this.attributeSubagent(filePath, knownMembers); + this.attributionCache.set(cacheKey, attribution); + if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) { + const oldestKey = this.attributionCache.keys().next().value; + if (oldestKey) this.attributionCache.delete(oldestKey); + } + return attribution; + } + private async parseSubagentSummary( filePath: string, projectId: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 69790de0..3306ad81 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_PREFIX_TAG, @@ -31,7 +32,11 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; -import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { + isInboxNoiseMessage, + type ParsedPermissionRequest, + parsePermissionRequest, +} from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -40,7 +45,11 @@ import { type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; -import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; +import { + extractToolPreview, + extractToolResultPreview, + formatToolSummaryFromCalls, +} from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { type ChildProcess, type spawn } from 'child_process'; import { randomUUID } from 'crypto'; @@ -75,6 +84,7 @@ function killTeamProcess(child: ChildProcess | null | undefined): void { } import type { + ActiveToolCall, CrossTeamSendResult, InboxMessage, LeadContextUsage, @@ -90,6 +100,7 @@ import type { TeamProvisioningState, TeamRuntimeState, TeamTask, + ToolActivityEventPayload, ToolApprovalAutoResolved, ToolApprovalEvent, ToolApprovalRequest, @@ -98,7 +109,8 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { createController, protocols } = agentTeamsControllerModule; +const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } = + agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; @@ -250,6 +262,8 @@ interface ProvisioningRun { fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; waitingTasksSince: number | null; provisioningComplete: boolean; + /** Path to the generated MCP config file for later cleanup. */ + mcpConfigPath: string | null; isLaunch: boolean; leadRelayCapture: { leadName: string; @@ -270,6 +284,8 @@ interface ProvisioningRun { leadMsgSeq: number; /** Accumulated tool_use details between text messages. */ pendingToolCalls: ToolCallMeta[]; + /** Active runtime tool calls keyed by tool_use_id. */ + activeToolCalls: Map; /** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */ pendingDirectCrossTeamSendRefresh: boolean; /** Throttle timestamp for emitting inbox refresh events for lead text. */ @@ -314,6 +330,8 @@ interface ProvisioningRun { } | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; + /** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */ + processedPermissionRequestIds: Set; /** * Post-compact context reinjection lifecycle. * - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject. @@ -387,11 +405,8 @@ async function ensureCwdExists(cwd: string): Promise { } } -function wrapInAgentBlock(text: string): string { - const trimmed = text.trim(); - if (trimmed.length === 0) return ''; - return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; -} +/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ +const wrapInAgentBlock = wrapAgentBlock; function indentMultiline(text: string, indent: string): string { return text @@ -555,7 +570,7 @@ export function buildAddMemberSpawnMessage( return ( `A new teammate "${member.name}"${roleHint} has been added to the team. ` + - `Please spawn them immediately using the Task tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + + `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + indentMultiline(prompt, ' ') ); } @@ -680,8 +695,8 @@ function buildPersistentLeadContext(opts: { `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + - `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + - `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + + `\n - ALLOWED: You may use the Agent tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Agent tool with team_name + name.` + `\n - TASK BOARD FIRST (MANDATORY): Do NOT do substantial work silently or off-board.` + `\n - Before you start meaningful implementation, debugging, research, review, or follow-up work, make sure there is a visible team-board task for it and that task is assigned to you.` + `\n - If the user asks for new work, your first move is to create/update the relevant board task(s), then start work from those tasks.` + @@ -901,8 +916,9 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { const step2Block = isSolo ? '2) Skip — this is a solo team with no teammates to spawn.' - : `2) Spawn each member as a live teammate using the Task tool: - - team_name: “${request.teamName}” + : `2) Spawn each member as a live teammate using the **Agent** tool: + CRITICAL: Every Agent call MUST include team_name=”${request.teamName}”. Without team_name the agent becomes an ephemeral subagent that exits immediately instead of a persistent teammate. + - team_name: “${request.teamName}” ← MANDATORY, never omit - name: the member's name (see per-member list below) - subagent_type: “general-purpose” - IMPORTANT: Use the exact prompt shown for each member. @@ -1006,8 +1022,9 @@ function buildLaunchPrompt( }) .join('\n\n'); - step2And3Block = `2) Spawn each existing member as a live teammate using the Task tool: - - team_name: "${request.teamName}" + step2And3Block = `2) Spawn each existing member as a live teammate using the **Agent** tool: + CRITICAL: Every Agent call MUST include team_name="${request.teamName}". Without team_name the agent becomes an ephemeral subagent that exits immediately instead of a persistent teammate. + - team_name: "${request.teamName}" ← MANDATORY, never omit - name: the member's name - subagent_type: "general-purpose" - IMPORTANT: Use the exact prompt shown for each member. @@ -1260,7 +1277,7 @@ export class TeamProvisioningService { private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; private static readonly HELP_CACHE_TTL_MS = 5 * 60 * 1000; - private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS; + private toolApprovalSettingsByTeam = new Map(); private pendingTimeouts = new Map(); private inFlightResponses = new Set(); private controlApiBaseUrlResolver: (() => Promise) | null = null; @@ -1667,6 +1684,40 @@ export class TeamProvisioningService { return text.length > 0 ? text : null; } + private extractStreamContentBlocks(msg: Record): Record[] { + const topLevelContent = msg.content; + if (Array.isArray(topLevelContent)) { + return topLevelContent as Record[]; + } + + const message = msg.message; + if (!message || typeof message !== 'object') return []; + const innerContent = (message as Record).content; + return Array.isArray(innerContent) ? (innerContent as Record[]) : []; + } + + private hasCapturedVisibleMessageToUser(content: Record[]): boolean { + return content.some((part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'tool_use' || typeof part.name !== 'string') return false; + + // Only native SendMessage(to="user") is guaranteed to be materialized as a + // visible outbound message by captureSendMessages(). + // Keep this intentionally narrower than captureSendMessages(): if another tool path + // later starts creating its own user-visible row, expand this helper in lockstep. + if (part.name !== 'SendMessage') return false; + + const input = part.input; + if (!input || typeof input !== 'object') return false; + const inp = input as Record; + const target = ( + typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : '' + ).trim(); + + return target.toLowerCase() === 'user'; + }); + } + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, @@ -1745,6 +1796,17 @@ export class TeamProvisioningService { const blocks = parseAllTeammateMessages(rawText); if (blocks.length === 0) return; + // Intercept teammate permission_request messages delivered natively via stdout. + // This runs even during provisioning (unlike relayLeadInboxMessages which waits + // for provisioningComplete). The lead already received the message — we can't + // prevent that — but we create a ToolApprovalRequest so the user sees the dialog. + for (const block of blocks) { + const perm = parsePermissionRequest(block.content); + if (perm) { + this.handleTeammatePermissionRequest(run, perm, new Date().toISOString()); + } + } + const crossTeamBlocks = blocks.flatMap((block) => { const origin = parseCrossTeamPrefix(block.content); const sourceTeam = origin?.from.includes('.') ? origin.from.split('.', 1)[0] : null; @@ -1822,6 +1884,9 @@ export class TeamProvisioningService { color: message.color, toolSummary: message.toolSummary, toolCalls: message.toolCalls, + messageKind: message.messageKind, + slashCommand: message.slashCommand, + commandOutput: message.commandOutput, }); } catch (error) { logger.warn(`[${teamName}] sent-message persist failed: ${String(error)}`); @@ -1850,6 +1915,9 @@ export class TeamProvisioningService { color: message.color, toolSummary: message.toolSummary, toolCalls: message.toolCalls, + messageKind: message.messageKind, + slashCommand: message.slashCommand, + commandOutput: message.commandOutput, }); } catch (error) { logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`); @@ -1977,8 +2045,12 @@ export class TeamProvisioningService { this.mainWindowRef = win; } - updateToolApprovalSettings(settings: ToolApprovalSettings): void { - this.toolApprovalSettings = settings; + private getToolApprovalSettings(teamName: string): ToolApprovalSettings { + return this.toolApprovalSettingsByTeam.get(teamName) ?? DEFAULT_TOOL_APPROVAL_SETTINGS; + } + + updateToolApprovalSettings(teamName: string, settings: ToolApprovalSettings): void { + this.toolApprovalSettingsByTeam.set(teamName, settings); this.reEvaluatePendingApprovals(); } @@ -2066,6 +2138,94 @@ export class TeamProvisioningService { }); } + private emitToolActivity(run: ProvisioningRun, payload: ToolActivityEventPayload): void { + if (!this.isCurrentTrackedRun(run)) return; + this.teamChangeEmitter?.({ + type: 'tool-activity', + teamName: run.teamName, + runId: run.runId, + detail: JSON.stringify(payload), + }); + } + + private startRuntimeToolActivity( + run: ProvisioningRun, + memberName: string, + block: Record + ): void { + const rawId = typeof block.id === 'string' ? block.id.trim() : ''; + if (!rawId) return; + + const toolUseId = rawId; + if (run.activeToolCalls.has(toolUseId)) return; + + const toolName = typeof block.name === 'string' ? block.name : 'unknown'; + const input = (block.input ?? {}) as Record; + const activity: ActiveToolCall = { + memberName, + toolUseId, + toolName, + preview: extractToolPreview(toolName, input), + startedAt: nowIso(), + state: 'running', + source: 'runtime', + }; + + run.activeToolCalls.set(toolUseId, activity); + this.emitToolActivity(run, { + action: 'start', + activity: { + memberName: activity.memberName, + toolUseId: activity.toolUseId, + toolName: activity.toolName, + preview: activity.preview, + startedAt: activity.startedAt, + source: activity.source, + }, + }); + } + + private finishRuntimeToolActivity( + run: ProvisioningRun, + toolUseId: string, + resultContent: unknown, + isError: boolean + ): void { + const active = run.activeToolCalls.get(toolUseId); + if (!active) return; + + run.activeToolCalls.delete(toolUseId); + this.emitToolActivity(run, { + action: 'finish', + memberName: active.memberName, + toolUseId, + finishedAt: nowIso(), + resultPreview: extractToolResultPreview(resultContent), + isError, + }); + } + + private resetRuntimeToolActivity(run: ProvisioningRun, memberName?: string): void { + if (run.activeToolCalls.size === 0) return; + + if (!memberName) { + run.activeToolCalls.clear(); + this.emitToolActivity(run, { action: 'reset' }); + return; + } + + let removed = false; + for (const [toolUseId, active] of run.activeToolCalls.entries()) { + if (active.memberName !== memberName) continue; + run.activeToolCalls.delete(toolUseId); + removed = true; + } + + if (removed) { + this.emitToolActivity(run, { action: 'reset', memberName }); + } + } + /** * Update spawn status for a specific team member and emit a change event. */ @@ -2482,7 +2642,7 @@ export class TeamProvisioningService { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; // If retry messages are flowing, they are more informative than our // generic stall text — don't overwrite progress.message / severity. @@ -2493,7 +2653,7 @@ export class TeamProvisioningService { ...run.progress, updatedAt: nowIso(), ...(!retryActive && { - message: `CLI not responding for ${elapsed} — possible rate limit`, + message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), assistantOutput: run.provisioningOutputParts.join('\n\n'), @@ -2519,15 +2679,15 @@ export class TeamProvisioningService { private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; if (silenceSec < 60) { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is running but not producing output yet. ` + - `This may be caused by an API delay (rate limit / model cooldown) — ` + - `the SDK retries automatically.\n\n` + + `The process is running but not producing output yet. Cloud sometimes delays logs, ` + + `and short waits like this are normal. The SDK also retries automatically if the ` + + `request briefly hits rate limiting.\n\n` + `Waiting...` ); } @@ -2536,9 +2696,10 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is still not responding. Likely delayed due to rate limiting ` + - `(error 429 / model cooldown). The SDK retries the request automatically — ` + - `this usually resolves within 1-3 minutes.\n\n` + + `The process is still waiting on Cloud. Logs can sometimes show up after ` + + `1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` + + `request hits rate limiting (error 429 / model cooldown).\n\n` + + `If there is still no output after 2 minutes, that starts to look unusual.\n\n` + `You can cancel and try again later if the wait continues.` ); } @@ -2549,15 +2710,23 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Extended CLI wait** (silent for ${elapsed})\n\n` + - `Model **${modelName}**${effortLabel} appears to be under heavy load and is not responding. ` + - `Most likely this is a 429 error (rate limit / model cooldown).\n\n` + - `The process has been silent for over ${mins} minutes. Possible causes:\n` + + `Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` + + `but no logs for ${elapsed} is already unusual.\n\n` + + `Possible causes:\n` + `- Rate limiting / model cooldown (429) — SDK retries automatically\n` + - `- API server overload for this model\n\n` + + `- API server overload for this model\n` + + `- A stalled or delayed Cloud response\n\n` + `Consider canceling and trying with a different model.` ); } + private buildStallProgressMessage(silenceSec: number, elapsed: string): string { + if (silenceSec < 120) { + return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`; + } + return `Still waiting on Cloud response for ${elapsed} — this is unusual`; + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -2642,6 +2811,32 @@ export class TeamProvisioningService { return; } + // Verify --mcp-config still exists; regenerate if deleted (e.g. by stale GC) + const mcpFlagIdx = ctx.args.indexOf('--mcp-config'); + if (mcpFlagIdx !== -1 && mcpFlagIdx + 1 < ctx.args.length) { + const existingConfigPath = ctx.args[mcpFlagIdx + 1]; + try { + await fs.promises.access(existingConfigPath, fs.constants.F_OK); + } catch { + logger.warn(`[${run.teamName}] MCP config ${existingConfigPath} missing, regenerating`); + try { + const newConfigPath = await this.mcpConfigBuilder.writeConfigFile(ctx.cwd); + ctx.args[mcpFlagIdx + 1] = newConfigPath; + run.mcpConfigPath = newConfigPath; + logger.info(`[${run.teamName}] Regenerated MCP config at ${newConfigPath}`); + } catch (regenErr) { + run.authRetryInProgress = false; + const progress = updateProgress(run, 'failed', 'Failed to regenerate MCP config', { + error: regenErr instanceof Error ? regenErr.message : String(regenErr), + cliLogsTail: extractCliLogsFromRun(run), + }); + run.onProgress(progress); + this.cleanupRun(run); + return; + } + } + } + // Respawn with saved context — CLI handles its own auth refresh. let child: ReturnType; try { @@ -2922,12 +3117,14 @@ export class TeamProvisioningService { apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, + mcpConfigPath: null, isLaunch: false, fsPhase: 'waiting_config', leadRelayCapture: null, activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + activeToolCalls: new Map(), pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, @@ -2941,6 +3138,7 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, @@ -2968,6 +3166,7 @@ export class TeamProvisioningService { let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); + run.mcpConfigPath = mcpConfigPath; } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -3027,6 +3226,9 @@ export class TeamProvisioningService { joinedAt: Date.now(), })) ); + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, spawnArgs, { cwd: request.cwd, @@ -3040,6 +3242,10 @@ export class TeamProvisioningService { const tasksDir = path.join(getTasksBasePath(), request.teamName); await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {}); await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {}); + if (run.mcpConfigPath) { + await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); + run.mcpConfigPath = null; + } this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); throw error; @@ -3354,12 +3560,14 @@ export class TeamProvisioningService { apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, + mcpConfigPath: null, isLaunch: true, fsPhase: 'waiting_members', leadRelayCapture: null, activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + activeToolCalls: new Map(), pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, @@ -3373,6 +3581,7 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, @@ -3422,6 +3631,7 @@ export class TeamProvisioningService { let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); + run.mcpConfigPath = mcpConfigPath; } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -3466,12 +3676,19 @@ export class TeamProvisioningService { // Without it, CLI creates a fresh session ID automatically. try { + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, launchArgs, { cwd: request.cwd, env: { ...shellEnv }, stdio: ['pipe', 'pipe', 'pipe'], }); } catch (error) { + if (run.mcpConfigPath) { + await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {}); + run.mcpConfigPath = null; + } this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); await this.restorePrelaunchConfig(request.teamName); @@ -3889,24 +4106,71 @@ export class TeamProvisioningService { } const work = (async (): Promise => { - const runId = this.getAliveRunId(teamName); + const runId = this.getAliveRunId(teamName) ?? this.getProvisioningRunId(teamName); if (!runId) return 0; const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; - if (!run.provisioningComplete) return 0; - - const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + // Permission request scan runs even during provisioning — teammates may need + // tool approval before the lead's first turn completes. CLI marks inbox messages + // as read after native delivery, so we must scan ALL messages (including read). let config: Awaited> | null = null; try { config = await this.configReader.getConfig(teamName); } catch { - return 0; + // config not ready yet during early provisioning — skip scan + } + if (config) { + const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; + try { + const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + const permMsgsToMarkRead: { messageId: string }[] = []; + const runStartedAtMs = Date.parse(run.startedAt); + for (const msg of leadInboxMessages) { + if (typeof msg.text !== 'string') continue; + const perm = parsePermissionRequest(msg.text); + if (!perm) continue; + // Skip permission_requests from previous runs — they're stale + const msgTs = Date.parse(msg.timestamp); + if ( + Number.isFinite(msgTs) && + Number.isFinite(runStartedAtMs) && + msgTs < runStartedAtMs + ) { + continue; + } + // Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds + this.handleTeammatePermissionRequest(run, perm, msg.timestamp); + // Mark unread permission_request messages as read to prevent stale unread indicators + if (!msg.read && this.hasStableMessageId(msg)) { + permMsgsToMarkRead.push({ messageId: msg.messageId }); + } + } + if (permMsgsToMarkRead.length > 0) { + await this.markInboxMessagesRead(teamName, leadName, permMsgsToMarkRead).catch( + () => {} + ); + } + } catch { + // best-effort — inbox may not exist yet + } + } + + if (!run.provisioningComplete) return 0; + + const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + + // Re-read config if needed (already fetched above but guard provisioningComplete path) + if (!config) { + try { + config = await this.configReader.getConfig(teamName); + } catch { + return 0; + } } if (!config) return 0; const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; - let leadInboxMessages: Awaited> = []; try { leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); @@ -4027,12 +4291,27 @@ export class TeamProvisioningService { ); const deferredIds = new Set(deferredByAge.map((m) => m.messageId)); + // Category 4: teammate permission requests — filter from actionable so they're + // NOT relayed to the lead. The actual interception + ToolApprovalRequest emission + // is handled by the early scan above (which checks processedPermissionRequestIds). + const permissionRequestIds = new Set( + unread + .filter( + (m) => + !permanentlyIgnoredIds.has(m.messageId) && + !deferredIds.has(m.messageId) && + parsePermissionRequest(m.text) !== null + ) + .map((m) => m.messageId) + ); + // Actionable: everything not in any category. const actionableUnread = unread.filter( (m) => !permanentlyIgnoredIds.has(m.messageId) && !nativeMatchedMessageIds.has(m.messageId) && - !deferredIds.has(m.messageId) + !deferredIds.has(m.messageId) && + !permissionRequestIds.has(m.messageId) ); // Layer 3: schedule retry timers. @@ -4395,7 +4674,20 @@ export class TeamProvisioningService { const inp = input as Record; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; - if (!teamName || !memberName) continue; + if (!memberName) continue; + if (!teamName) { + logger.warn( + `[captureTeamSpawnEvents] Agent call for "${memberName}" is missing team_name — ` + + `teammate will be an ephemeral subagent, not a persistent member of "${run.teamName}"` + ); + this.setMemberSpawnStatus( + run, + memberName, + 'error', + `Agent spawn for "${memberName}" is missing team_name — spawned as ephemeral subagent instead of persistent teammate` + ); + continue; + } // Only track spawns for this team if (teamName !== run.teamName) continue; this.setMemberSpawnStatus(run, memberName, 'spawning'); @@ -4854,29 +5146,43 @@ export class TeamProvisioningService { // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} if (msg.type === 'user') { + // Check for permission_request in raw user message text BEFORE teammate-message parsing. + // The permission_request may arrive as plain JSON without wrapper, + // and handleNativeTeammateUserMessage only processes blocks. + const rawUserText = this.extractStreamUserText(msg); + const content = this.extractStreamContentBlocks(msg); + if (rawUserText) { + const perm = parsePermissionRequest(rawUserText); + if (perm) { + logger.warn( + `[${run.teamName}] [PERM-TRACE] Intercepted permission_request from stdout user message: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` + ); + this.handleTeammatePermissionRequest(run, perm, new Date().toISOString()); + } else if (rawUserText.includes('permission_request')) { + // Log near-miss: text contains "permission_request" but wasn't parsed + logger.warn( + `[${run.teamName}] [PERM-TRACE] stdout user message contains "permission_request" but parsePermissionRequest returned null. Text preview: ${rawUserText.slice(0, 300)}` + ); + } + } + for (const block of content) { + if (block?.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue; + this.finishRuntimeToolActivity( + run, + block.tool_use_id, + block.content, + block.is_error === true + ); + } this.handleNativeTeammateUserMessage(run, msg); return; } if (msg.type === 'assistant') { - const content = Array.isArray(msg.content) - ? (msg.content as Record[]) - : (() => { - const message = msg.message; - if (!message || typeof message !== 'object') return null; - const inner = (message as Record).content; - return Array.isArray(inner) ? (inner as Record[]) : null; - })(); + const content = this.extractStreamContentBlocks(msg); - const hasCapturedSendMessage = (content ?? []).some((part) => { - if (!part || typeof part !== 'object') return false; - if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; - const input = part.input; - if (!input || typeof input !== 'object') return false; - const recipient = (input as Record).recipient; - return typeof recipient === 'string' && recipient.trim().length > 0; - }); + const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content); - const textParts = (content ?? []) + const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) { @@ -4895,26 +5201,27 @@ export class TeamProvisioningService { run.provisioningOutputParts.push(text); } - if (run.leadRelayCapture) { + // Once relay capture is settled, later assistant chunks belong to the normal live + // message flow. Keeping them in the capture branch would drop them on the floor + // until relayLeadInboxMessages() finally clears run.leadRelayCapture. + if (run.leadRelayCapture && !run.leadRelayCapture.settled) { const capture = run.leadRelayCapture; - if (!capture.settled) { - capture.textParts.push(text); - if (capture.idleHandle) { - clearTimeout(capture.idleHandle); - } - capture.idleHandle = setTimeout(() => { - const combined = capture.textParts.join('\n').trim(); - capture.resolveOnce(combined); - }, capture.idleMs); + capture.textParts.push(text); + if (capture.idleHandle) { + clearTimeout(capture.idleHandle); } + capture.idleHandle = setTimeout(() => { + const combined = capture.textParts.join('\n').trim(); + capture.resolveOnce(combined); + }, capture.idleMs); } else if (run.provisioningComplete) { // Push each assistant text block as a separate live message (per-message pattern). - // When the same assistant message includes SendMessage(...), skip text — + // When the same assistant message includes a user-visible message send, skip text — // captureSendMessages() handles the visible outbound message separately. if ( !run.silentUserDmForward && !run.suppressPostCompactReminderOutput && - !hasCapturedSendMessage + !hasCapturedVisibleMessageToUser ) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { @@ -4928,7 +5235,7 @@ export class TeamProvisioningService { } else { // Pre-ready: keep showing provisioning narration in the banner, but also mirror it // into the live cache so Messages/Activity can show the earliest assistant output. - if (!run.silentUserDmForward && !hasCapturedSendMessage) { + if (!run.silentUserDmForward && !hasCapturedVisibleMessageToUser) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { this.pushLiveLeadTextMessage( @@ -4944,7 +5251,7 @@ export class TeamProvisioningService { // 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. // Works in both pre-ready and post-ready phases so early live messages get tool metadata. - for (const block of content ?? []) { + for (const block of content) { if ( block?.type === 'tool_use' && typeof block.name === 'string' && @@ -4954,19 +5261,21 @@ export class TeamProvisioningService { run.pendingToolCalls.push({ name: block.name, preview: extractToolPreview(block.name, input), + toolUseId: typeof block.id === 'string' ? block.id : undefined, }); + this.startRuntimeToolActivity(run, this.getRunLeadName(run), block); } } // Track member spawn events from Task tool_use blocks with team_name. // When the lead calls Task(team_name=X, name=Y), it means member Y is being spawned. - this.captureTeamSpawnEvents(run, content ?? []); + this.captureTeamSpawnEvents(run, content); // Capture SendMessage tool_use blocks from assistant output. // Works in both pre-ready and post-ready phases so outbound runtime messages // are visible in our team message artifacts even if Claude's own routing drifts. if (!run.silentUserDmForward || run.silentUserDmForward.mode === 'member_inbox_relay') { - this.captureSendMessages(run, content ?? []); + this.captureSendMessages(run, content); } // Extract context window usage from message.usage for real-time tracking. @@ -4985,12 +5294,24 @@ export class TeamProvisioningService { : 0; const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; + // Total context window usage = all three token categories + // input_tokens = tokens AFTER last cache breakpoint (small) + // cache_creation = tokens written to cache (first request) + // cache_read = tokens read from cache (subsequent requests) — these ARE in context window const currentTokens = inputTokens + cacheCreation + cacheRead; if (!run.leadContextUsage) { + // Determine initial context window from model selection + // computeEffectiveTeamModel() defaults to 'opus[1m]' when no model selected + const modelStr = (run.request.model ?? '').toLowerCase(); + const isHaiku = modelStr.includes('haiku'); + const isLimitedContext = run.request.limitContext === true; + // limitContext=true → 200K, haiku → 200K, [1m] → 1M, default → 1M (opus[1m]) + const initialContextWindow = isLimitedContext || isHaiku ? 200_000 : 1_000_000; + run.leadContextUsage = { currentTokens, - contextWindow: 200_000, + contextWindow: initialContextWindow, lastUsageMessageId: msgId, lastEmittedAt: 0, }; @@ -5110,6 +5431,7 @@ export class TeamProvisioningService { ); } + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); } if (run.pendingDirectCrossTeamSendRefresh) { @@ -5196,6 +5518,7 @@ export class TeamProvisioningService { `[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)` ); } + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); } } @@ -5451,6 +5774,7 @@ export class TeamProvisioningService { } catch (error) { // Strict drop-after-attempt — do not re-arm. clearPostCompactReminderState(run); + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); logger.warn( `[${run.teamName}] post-compact reminder injection failed: ${ @@ -5501,7 +5825,11 @@ export class TeamProvisioningService { }; // Check auto-allow rules before prompting user - const autoResult = shouldAutoAllow(this.toolApprovalSettings, toolName, toolInput); + const autoResult = shouldAutoAllow( + this.getToolApprovalSettings(run.teamName), + toolName, + toolInput + ); if (autoResult.autoAllow) { logger.info(`[${run.teamName}] Auto-allowing ${toolName} (${autoResult.reason})`); this.autoAllowControlRequest(run, requestId); @@ -5523,6 +5851,72 @@ export class TeamProvisioningService { this.maybeShowToolApprovalOsNotification(run, approval); } + /** + * Handles a teammate permission_request received via inbox message. + * Converts it to a ToolApprovalRequest and feeds it into the existing approval flow. + */ + private handleTeammatePermissionRequest( + run: ProvisioningRun, + perm: ParsedPermissionRequest, + messageTimestamp: string + ): void { + // Skip if already tracked (idempotency — multiple paths can trigger this: + // early inbox scan, stdout parsing, native message blocks, relay Category 4) + if (run.processedPermissionRequestIds.has(perm.requestId)) return; + if (run.pendingApprovals.has(perm.requestId)) return; + run.processedPermissionRequestIds.add(perm.requestId); + + logger.warn( + `[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` + ); + + const approval: ToolApprovalRequest = { + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + source: perm.agentId, + toolName: perm.toolName, + toolInput: perm.input, + receivedAt: messageTimestamp || new Date().toISOString(), + teamColor: run.request.color, + teamDisplayName: run.request.displayName, + permissionSuggestions: + perm.permissionSuggestions.length > 0 ? perm.permissionSuggestions : undefined, + }; + + const autoResult = shouldAutoAllow( + this.getToolApprovalSettings(run.teamName), + perm.toolName, + perm.input + ); + if (autoResult.autoAllow) { + logger.info( + `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` + ); + void this.respondToTeammatePermission( + run, + perm.agentId, + perm.requestId, + true, + undefined, + perm.permissionSuggestions + ); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + reason: 'auto_allow_category', + } as ToolApprovalAutoResolved); + return; + } + + run.pendingApprovals.set(perm.requestId, approval); + this.emitToolApprovalEvent(approval); + this.startApprovalTimeout(run, perm.requestId); + this.maybeShowToolApprovalOsNotification(run, approval); + } + /** * Shows a native OS notification for a pending tool approval when the app * is not in focus. On macOS, adds Allow/Deny action buttons that respond @@ -5652,7 +6046,7 @@ export class TeamProvisioningService { response: { subtype: 'success', request_id: requestId, - response: { behavior: 'allow' }, + response: { behavior: 'allow', updatedInput: {} }, }, }; @@ -5672,7 +6066,7 @@ export class TeamProvisioningService { } private startApprovalTimeout(run: ProvisioningRun, requestId: string): void { - const { timeoutAction, timeoutSeconds } = this.toolApprovalSettings; + const { timeoutAction, timeoutSeconds } = this.getToolApprovalSettings(run.teamName); if (timeoutAction === 'wait') return; const timeoutMs = timeoutSeconds * 1000; @@ -5682,7 +6076,7 @@ export class TeamProvisioningService { if (!this.tryClaimResponse(requestId)) return; // Read CURRENT settings (not captured closure) in case user changed action - const currentAction = this.toolApprovalSettings.timeoutAction; + const currentAction = this.getToolApprovalSettings(run.teamName).timeoutAction; if (currentAction === 'wait') { // Settings changed to 'wait' but timer fired before reEvaluatePendingApprovals cleared it this.inFlightResponses.delete(requestId); @@ -5691,6 +6085,31 @@ export class TeamProvisioningService { const allow = currentAction === 'allow'; logger.info(`[${run.teamName}] Timeout ${allow ? 'allowing' : 'denying'} ${requestId}`); + const approval = run.pendingApprovals.get(requestId); + if (approval && approval.source !== 'lead') { + // Teammate request — apply permission_suggestions to project settings. + this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + allow ? undefined : 'Timed out — auto-denied by settings', + approval.permissionSuggestions + ).finally(() => { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId, + runId: run.runId, + teamName: run.teamName, + reason: allow ? 'timeout_allow' : 'timeout_deny', + } as ToolApprovalAutoResolved); + }); + return; + } + if (allow) { this.autoAllowControlRequest(run, requestId); } else { @@ -5746,16 +6165,25 @@ export class TeamProvisioningService { private reEvaluatePendingApprovals(): void { for (const [, run] of this.runs) { + const settings = this.getToolApprovalSettings(run.teamName); const toRemove: string[] = []; for (const [requestId, approval] of run.pendingApprovals) { - const result = shouldAutoAllow( - this.toolApprovalSettings, - approval.toolName, - approval.toolInput - ); + const result = shouldAutoAllow(settings, approval.toolName, approval.toolInput); if (result.autoAllow) { this.clearApprovalTimeout(requestId); - this.autoAllowControlRequest(run, requestId); + if (!this.tryClaimResponse(requestId)) continue; + if (approval.source !== 'lead') { + void this.respondToTeammatePermission( + run, + approval.source, + requestId, + true, + undefined, + approval.permissionSuggestions + ); + } else { + this.autoAllowControlRequest(run, requestId); + } this.dismissApprovalNotification(requestId); toRemove.push(requestId); this.emitToolApprovalEvent({ @@ -5765,22 +6193,17 @@ export class TeamProvisioningService { teamName: run.teamName, reason: 'auto_allow_category', } as ToolApprovalAutoResolved); - } else if ( - this.toolApprovalSettings.timeoutAction !== 'wait' && - !this.pendingTimeouts.has(requestId) - ) { + } else if (settings.timeoutAction !== 'wait' && !this.pendingTimeouts.has(requestId)) { // Settings changed from 'wait' to allow/deny — start timer for already pending items this.startApprovalTimeout(run, requestId); - } else if ( - this.toolApprovalSettings.timeoutAction === 'wait' && - this.pendingTimeouts.has(requestId) - ) { + } else if (settings.timeoutAction === 'wait' && this.pendingTimeouts.has(requestId)) { // Settings changed TO 'wait' — clear existing timers this.clearApprovalTimeout(requestId); } } for (const requestId of toRemove) { run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); } } } @@ -5821,19 +6244,58 @@ export class TeamProvisioningService { return; } + const approval = run.pendingApprovals.get(requestId)!; + + // Teammate permission requests: apply permission_suggestions to project settings + if (approval.source !== 'lead') { + try { + await this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + message, + approval.permissionSuggestions + ); + } finally { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + } + return; + } + if (!run.child?.stdin?.writable) { throw new Error(`Team "${teamName}" process stdin is not writable`); } // IMPORTANT: request_id is NESTED inside response, NOT top-level // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) + const allowResponse: Record = { behavior: 'allow', updatedInput: {} }; + // For AskUserQuestion: pass user's answers via updatedInput so the CLI + // can deliver them without re-prompting. Format follows --permission-prompt-tool spec. + if (allow && message) { + const pending = run.pendingApprovals.get(requestId); + if (pending?.toolName === 'AskUserQuestion') { + try { + const answers = JSON.parse(message) as Record; + allowResponse.updatedInput = { ...pending.toolInput, answers }; + } catch { + // If message isn't JSON, use as-is for the first question + const questions = (pending.toolInput.questions as { question?: string }[]) ?? []; + const answers: Record = {}; + if (questions[0]?.question) answers[questions[0].question] = message; + allowResponse.updatedInput = { ...pending.toolInput, answers }; + } + } + } const response = allow ? { type: 'control_response', response: { subtype: 'success', request_id: requestId, - response: { behavior: 'allow' }, + response: allowResponse, }, } : { @@ -5876,6 +6338,231 @@ export class TeamProvisioningService { } } + /** + * Respond to a teammate's permission_request by applying permission_suggestions. + * + * FACT: Claude Code teammate runtime sends permission_request via SendMessage (inbox protocol). + * FACT: Writing permission_response to teammate inbox does NOT work - runtime ignores it. + * FACT: control_response via stdin does NOT work for teammate requests - request_id doesn't match. + * FACT: permission_suggestions.destination "localSettings" refers to {cwd}/.claude/settings.local.json. + * FACT: Claude Code CLI reads this file via --setting-sources user,project,local. + * + * When allow=true: applies permission_suggestions (adds tool rules to project settings). + * When allow=false: no action needed - tool stays blocked by default. + */ + private async respondToTeammatePermission( + run: ProvisioningRun, + agentId: string, + requestId: string, + allow: boolean, + _message?: string, + permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[] + ): Promise { + if (!allow) { + logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`); + return; + } + + // Apply permission_suggestions: add tool rules to project settings file + const suggestions = permissionSuggestions ?? []; + if (suggestions.length === 0) { + logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`); + return; + } + + // Resolve project cwd from team config + let projectCwd: string | undefined; + try { + const config = await this.configReader.getConfig(run.teamName); + projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; + } catch { + // best-effort + } + if (!projectCwd) { + logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`); + return; + } + + for (const suggestion of suggestions) { + // Handle "setMode" suggestions (e.g. Write/Edit tools suggest acceptEdits mode) + // FACT: Write/Edit permission_requests have permission_suggestions: + // { type: "setMode", mode: "acceptEdits", destination: "session" } + // Since we can't change session mode of a subprocess, we translate to addRules. + if (suggestion.type === 'setMode') { + const mode = typeof suggestion.mode === 'string' ? suggestion.mode : ''; + let toolNames: string[] = []; + if (mode === 'acceptEdits') { + toolNames = ['Edit', 'Write', 'NotebookEdit']; + } else if (mode === 'bypassPermissions') { + // Broad approval — add common tools + toolNames = ['Edit', 'Write', 'NotebookEdit', 'Bash', 'Read', 'Grep', 'Glob']; + } + if (toolNames.length > 0) { + const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); + try { + await this.addPermissionRulesToSettings(settingsPath, toolNames, 'allow'); + logger.info( + `[${run.teamName}] Applied setMode "${mode}" for ${agentId}: ${toolNames.join(', ')} in ${settingsPath}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to apply setMode: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + continue; + } + + if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue; + + let toolNames = suggestion.rules + .map((r) => r.toolName) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + if (toolNames.length === 0) continue; + + // Expand teammate-safe operational tools only. + // This removes the bootstrap/task workflow race without accidentally granting + // admin/runtime tools like team_stop or kanban_clear. + if ( + toolNames.some((name) => + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES.includes(name) + ) + ) { + const merged = new Set([ + ...toolNames, + ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ]); + toolNames = Array.from(merged); + } + + const behavior = suggestion.behavior ?? 'allow'; + // FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json) + const settingsPath = + suggestion.destination === 'localSettings' + ? path.join(projectCwd, '.claude', 'settings.local.json') + : path.join(projectCwd, '.claude', 'settings.local.json'); // default to local + + try { + await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior); + logger.info( + `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} → ${behavior} in ${settingsPath}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to add permission rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + // Also attempt control_response via stdin — the lead runtime MAY forward it + // to the teammate subprocess. This was broken before (missing updatedInput: {}) + // but is now fixed. Belt-and-suspenders: settings handle future calls, + // control_response may unblock the CURRENT waiting prompt. + if (allow && run.child?.stdin?.writable) { + const controlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow', updatedInput: {} }, + }, + }; + run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { + if (err) { + logger.warn( + `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` + ); + } + }); + } + } + + /** + * Safely add tool names to the permissions.allow (or deny) array in a Claude settings file. + * Creates the file and parent directories if they don't exist. + * Merges with existing entries — never overwrites. + */ + private async addPermissionRulesToSettings( + settingsPath: string, + toolNames: string[], + behavior: string + ): Promise { + const dir = path.dirname(settingsPath); + await fs.promises.mkdir(dir, { recursive: true }); + + // Read existing settings (or start with empty object) + let settings: Record = {}; + try { + const raw = await fs.promises.readFile(settingsPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + settings = parsed as Record; + } + } catch { + // File doesn't exist or invalid JSON — start fresh + } + + // Ensure permissions object exists + if (!settings.permissions || typeof settings.permissions !== 'object') { + settings.permissions = {}; + } + const perms = settings.permissions as Record; + + // Target array: "allow" or "deny" based on behavior + const key = behavior === 'deny' ? 'deny' : 'allow'; + if (!Array.isArray(perms[key])) { + perms[key] = []; + } + const list = perms[key] as string[]; + + // Add tool names that aren't already in the list + const existing = new Set(list); + let added = 0; + for (const name of toolNames) { + if (!existing.has(name)) { + list.push(name); + added++; + } + } + + if (added === 0) return 0; // Nothing new to add + + await atomicWriteAsync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + return added; + } + + private async seedTeammateOperationalPermissionRules( + teamName: string, + projectCwd: string + ): Promise { + const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); + try { + // FACT: Teammates need both MCP tools AND standard file tools (Write/Edit). + // FACT: Standard tools use "setMode: acceptEdits" permission_suggestions, but + // we can't change subprocess session mode — so we pre-add them as allow rules. + const allTools = [ + ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + 'Edit', + 'Write', + 'NotebookEdit', + ]; + const added = await this.addPermissionRulesToSettings(settingsPath, allTools, 'allow'); + logger.info( + `[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to seed teammate operational MCP rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. @@ -5913,6 +6600,7 @@ export class TeamProvisioningService { } run.provisioningComplete = true; + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); // Clear provisioning timeout — no longer needed @@ -5971,6 +6659,15 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + // Force a post-ready detail refresh so Messages reload persisted lead_session + // texts from JSONL even if the last visible assistant output only reached disk. + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + runId: run.runId, + detail: 'lead-session-sync', + }); + // Fire "Team Launched" notification void this.fireTeamLaunchedNotification(run); @@ -6070,6 +6767,15 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + // Force a post-ready detail refresh so Messages reload persisted lead_session + // texts from JSONL even if the last visible assistant output only reached disk. + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + runId: run.runId, + detail: 'lead-session-sync', + }); + // Fire "Team Launched" notification void this.fireTeamLaunchedNotification(run); @@ -6377,6 +7083,7 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { + this.resetRuntimeToolActivity(run); this.setLeadActivity(run, 'offline'); run.pendingDirectCrossTeamSendRefresh = false; if (run.timeoutHandle) { @@ -6438,6 +7145,11 @@ export class TeamProvisioningService { this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId }); run.pendingApprovals.clear(); } + // Clean up the generated MCP config file (best-effort, fire-and-forget) + if (run.mcpConfigPath) { + void this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath); + run.mcpConfigPath = null; + } // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) this.runs.delete(run.runId); } @@ -6885,9 +7597,12 @@ export class TeamProvisioningService { USER: user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color', - // Ensure CLI reads/writes from the same Claude root as the app. - // This aligns teams/tasks locations when the app overrides claudeRootPath. - CLAUDE_CONFIG_DIR: getClaudeBasePath(), + // Only set CLAUDE_CONFIG_DIR when the user configured a custom path. + // Setting it to the default ~/.claude changes the macOS Keychain namespace + // for OAuth credential lookup, causing auth failures. (See issue #27) + ...(getClaudeBasePath() !== getAutoDetectedClaudeBasePath() + ? { CLAUDE_CONFIG_DIR: getClaudeBasePath() } + : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index a33ca76f..c23c7ccc 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -98,6 +98,39 @@ export class TeamSentMessagesStore { preview: typeof tc.preview === 'string' ? tc.preview : undefined, })) : undefined, + messageKind: + row.messageKind === 'slash_command' || + row.messageKind === 'slash_command_result' || + row.messageKind === 'task_comment_notification' + ? row.messageKind + : row.messageKind === 'default' + ? 'default' + : undefined, + slashCommand: + row.slashCommand && + typeof row.slashCommand === 'object' && + typeof row.slashCommand.name === 'string' && + typeof row.slashCommand.command === 'string' + ? { + name: row.slashCommand.name, + command: row.slashCommand.command, + args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined, + knownDescription: + typeof row.slashCommand.knownDescription === 'string' + ? row.slashCommand.knownDescription + : undefined, + } + : undefined, + commandOutput: + row.commandOutput && + typeof row.commandOutput === 'object' && + (row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') && + typeof row.commandOutput.commandLabel === 'string' + ? { + stream: row.commandOutput.stream, + commandLabel: row.commandOutput.commandLabel, + } + : undefined, }); } diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts new file mode 100644 index 00000000..5a4416b9 --- /dev/null +++ b/src/main/services/team/TeammateToolTracker.ts @@ -0,0 +1,520 @@ +import { extractToolPreview, extractToolResultPreview } from '@shared/utils/toolSummary'; +import * as fs from 'fs/promises'; + +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { ActiveToolCall, TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; + +const MAX_SEEN_FINISHED_IDS = 512; + +interface FileState { + memberName: string; + sessionId: string; + lastSize: number; + lastMtimeMs: number; + lineCarry: string; + activeTools: Map; + seenFinished: Set; +} + +interface TeamState { + enabled: boolean; + epoch: number; + filesByPath: Map; + refreshInFlight: boolean; + refreshQueued: boolean; +} + +interface AttributedSubagentFile { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; +} + +interface ParsedFileSnapshot { + lastSize: number; + lastMtimeMs: number; + lineCarry: string; + activeTools: Map; + seenFinished: Set; +} + +export class TeammateToolTracker { + private readonly stateByTeam = new Map(); + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly logSourceTracker: TeamLogSourceTracker, + private readonly emitTeamChange: (event: TeamChangeEvent) => void + ) {} + + async setTracking(teamName: string, enabled: boolean): Promise { + if (enabled) { + await this.enableTracking(teamName); + return; + } + await this.disableTracking(teamName); + } + + async dispose(): Promise { + await Promise.all( + [...this.stateByTeam.keys()].map((teamName) => this.disableTracking(teamName)) + ); + } + + handleLogSourceChange(teamName: string): void { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled) return; + void this.refreshTeam(teamName); + } + + handleTeamOffline(teamName: string): void { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled) return; + state.epoch += 1; + this.resetAllTrackedTools(teamName, state.filesByPath); + state.filesByPath.clear(); + state.refreshQueued = false; + } + + private getOrCreateState(teamName: string): TeamState { + const existing = this.stateByTeam.get(teamName); + if (existing) return existing; + const created: TeamState = { + enabled: false, + epoch: 0, + filesByPath: new Map(), + refreshInFlight: false, + refreshQueued: false, + }; + this.stateByTeam.set(teamName, created); + return created; + } + + private async enableTracking(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (state.enabled) { + await this.refreshTeam(teamName); + return; + } + state.enabled = true; + state.epoch += 1; + state.filesByPath.clear(); + state.refreshQueued = false; + await this.logSourceTracker.enableTracking(teamName, 'tool_activity'); + await this.refreshTeam(teamName); + } + + private async disableTracking(teamName: string): Promise { + const state = this.stateByTeam.get(teamName); + if (!state) { + await this.logSourceTracker.disableTracking(teamName, 'tool_activity'); + return; + } + state.enabled = false; + state.epoch += 1; + this.resetAllTrackedTools(teamName, state.filesByPath); + state.filesByPath.clear(); + state.refreshQueued = false; + await this.logSourceTracker.disableTracking(teamName, 'tool_activity'); + } + + private async refreshTeam(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.enabled) return; + + if (state.refreshInFlight) { + state.refreshQueued = true; + return; + } + + state.refreshInFlight = true; + try { + do { + state.refreshQueued = false; + const expectedEpoch = state.epoch; + await this.performRefresh(teamName, expectedEpoch); + } while (state.enabled && state.refreshQueued); + } finally { + state.refreshInFlight = false; + } + } + + private async performRefresh(teamName: string, expectedEpoch: number): Promise { + const state = this.stateByTeam.get(teamName); + if (!state?.enabled || state.epoch !== expectedEpoch) return; + + const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); + const currentState = this.stateByTeam.get(teamName); + if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; + + const fileByPath = new Map(attributedFiles.map((file) => [file.filePath, file])); + + for (const [filePath, fileState] of currentState.filesByPath.entries()) { + if (fileByPath.has(filePath)) continue; + this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]); + currentState.filesByPath.delete(filePath); + } + + for (const file of attributedFiles) { + const liveState = this.stateByTeam.get(teamName); + if (!liveState?.enabled || liveState.epoch !== expectedEpoch) return; + + const existing = liveState.filesByPath.get(file.filePath); + let stat; + try { + stat = await fs.stat(file.filePath); + } catch { + if (existing) { + this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]); + liveState.filesByPath.delete(file.filePath); + } + continue; + } + if (!stat.isFile()) continue; + + const attributionChanged = + existing && + (existing.memberName !== file.memberName || existing.sessionId !== file.sessionId); + + if (!existing || attributionChanged) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + if (existing) { + this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]); + } + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot( + teamName, + file, + attributionChanged ? null : (existing ?? null), + parsed + ) + ); + continue; + } + + if (stat.size < existing.lastSize) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot(teamName, file, existing, parsed) + ); + continue; + } + + if (stat.size === existing.lastSize && stat.mtimeMs === existing.lastMtimeMs) { + continue; + } + + if (stat.size === existing.lastSize) { + const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set( + file.filePath, + this.applyParsedSnapshot(teamName, file, existing, parsed) + ); + continue; + } + + const nextState = await this.applyDelta(teamName, file, existing, stat.size, stat.mtimeMs); + const latestState = this.stateByTeam.get(teamName); + if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return; + latestState.filesByPath.set(file.filePath, nextState); + } + } + + private async parseFileSnapshot( + file: AttributedSubagentFile, + size: number, + mtimeMs: number + ): Promise { + const content = await fs.readFile(file.filePath, 'utf8').catch(() => ''); + const { lines, carry } = splitJsonLines(content); + const activeTools = new Map(); + const seenFinished = new Set(); + + for (const line of lines) { + this.consumeJsonLine(line, file, activeTools, seenFinished); + } + + return { + lastSize: size, + lastMtimeMs: mtimeMs, + lineCarry: carry, + activeTools, + seenFinished, + }; + } + + private applyParsedSnapshot( + teamName: string, + file: AttributedSubagentFile, + existing: FileState | null, + parsed: ParsedFileSnapshot + ): FileState { + const previousActive = existing?.activeTools ?? new Map(); + const nextActiveIds = new Set(parsed.activeTools.keys()); + const removedIds = [...previousActive.keys()].filter( + (toolUseId) => !nextActiveIds.has(toolUseId) + ); + if (removedIds.length > 0 && existing) { + this.emitTargetedReset(teamName, existing.memberName, removedIds); + } + + for (const [toolUseId, activity] of parsed.activeTools.entries()) { + if (previousActive.has(toolUseId)) continue; + this.emitStart(teamName, activity); + } + + return { + memberName: file.memberName, + sessionId: file.sessionId, + lastSize: parsed.lastSize, + lastMtimeMs: parsed.lastMtimeMs, + lineCarry: parsed.lineCarry, + activeTools: parsed.activeTools, + seenFinished: parsed.seenFinished, + }; + } + + private async applyDelta( + teamName: string, + file: AttributedSubagentFile, + fileState: FileState, + nextSize: number, + nextMtimeMs: number + ): Promise { + const nextActiveTools = new Map(fileState.activeTools); + const nextSeenFinished = new Set(fileState.seenFinished); + const appendedChunk = await readAppendedChunk(file.filePath, fileState.lastSize, nextSize); + const { lines, carry } = splitJsonLines(fileState.lineCarry + appendedChunk); + + for (const line of lines) { + this.consumeJsonLine(line, file, nextActiveTools, nextSeenFinished, { + emitStart: (activity) => this.emitStart(teamName, activity), + emitFinish: (activity, result) => this.emitFinish(teamName, activity, result), + }); + } + + return { + memberName: fileState.memberName, + sessionId: fileState.sessionId, + lastSize: nextSize, + lastMtimeMs: nextMtimeMs, + lineCarry: carry, + activeTools: nextActiveTools, + seenFinished: nextSeenFinished, + }; + } + + private consumeJsonLine( + line: string, + file: AttributedSubagentFile, + activeTools: Map, + seenFinished: Set, + emitters?: { + emitStart?: (activity: ActiveToolCall) => void; + emitFinish?: (activity: ActiveToolCall, result: FinishPayload) => void; + } + ): void { + let entry: Record; + try { + entry = JSON.parse(line) as Record; + } catch { + return; + } + + const timestamp = extractEntryTimestamp(entry) ?? new Date().toISOString(); + const content = extractEntryContent(entry); + if (!content) return; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const typedBlock = block; + if (typedBlock.type === 'tool_use') { + const rawId = typeof typedBlock.id === 'string' ? typedBlock.id.trim() : ''; + if (!rawId) continue; + const toolUseId = buildCompositeToolUseId(file.sessionId, rawId); + if (activeTools.has(toolUseId) || seenFinished.has(toolUseId)) continue; + const toolName = typeof typedBlock.name === 'string' ? typedBlock.name : 'Tool'; + const input = + typedBlock.input && typeof typedBlock.input === 'object' + ? (typedBlock.input as Record) + : {}; + const activity: ActiveToolCall = { + memberName: file.memberName, + toolUseId, + toolName, + preview: extractToolPreview(toolName, input), + startedAt: timestamp, + source: 'member_log', + state: 'running', + }; + activeTools.set(toolUseId, activity); + emitters?.emitStart?.(activity); + continue; + } + + if (typedBlock.type !== 'tool_result' || typeof typedBlock.tool_use_id !== 'string') continue; + const toolUseId = buildCompositeToolUseId(file.sessionId, typedBlock.tool_use_id); + const active = activeTools.get(toolUseId); + if (active) { + activeTools.delete(toolUseId); + pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS); + emitters?.emitFinish?.(active, { + finishedAt: timestamp, + resultPreview: extractToolResultPreview(typedBlock.content), + isError: typedBlock.is_error === true, + }); + continue; + } + + pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS); + } + } + + private emitStart(teamName: string, activity: ActiveToolCall): void { + const payload: ToolActivityEventPayload = { + action: 'start', + activity: { + memberName: activity.memberName, + toolUseId: activity.toolUseId, + toolName: activity.toolName, + preview: activity.preview, + startedAt: activity.startedAt, + source: activity.source, + }, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private emitFinish(teamName: string, activity: ActiveToolCall, result: FinishPayload): void { + const payload: ToolActivityEventPayload = { + action: 'finish', + memberName: activity.memberName, + toolUseId: activity.toolUseId, + finishedAt: result.finishedAt, + resultPreview: result.resultPreview, + isError: result.isError, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private emitTargetedReset(teamName: string, memberName: string, toolUseIds: string[]): void { + if (toolUseIds.length === 0) return; + const payload: ToolActivityEventPayload = { + action: 'reset', + memberName, + toolUseIds, + }; + this.emitTeamChange({ + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }); + } + + private resetAllTrackedTools(teamName: string, filesByPath: Map): void { + for (const fileState of filesByPath.values()) { + this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]); + } + } +} + +interface FinishPayload { + finishedAt: string; + resultPreview?: string; + isError?: boolean; +} + +function buildCompositeToolUseId(sessionId: string, rawToolUseId: string): string { + return `member_log:${sessionId}:${rawToolUseId}`; +} + +function extractEntryContent(entry: Record): Record[] | null { + if (Array.isArray(entry.content)) return entry.content as Record[]; + const message = entry.message; + if ( + message && + typeof message === 'object' && + Array.isArray((message as { content?: unknown[] }).content) + ) { + return (message as { content: Record[] }).content; + } + return null; +} + +function extractEntryTimestamp(entry: Record): string | null { + if (typeof entry.timestamp === 'string' && entry.timestamp.trim().length > 0) { + return entry.timestamp; + } + const message = entry.message; + if ( + message && + typeof message === 'object' && + typeof (message as { timestamp?: unknown }).timestamp === 'string' + ) { + return (message as { timestamp: string }).timestamp; + } + return null; +} + +function splitJsonLines(text: string): { lines: string[]; carry: string } { + const normalized = text.replace(/\r\n/g, '\n'); + const rawParts = normalized.split('\n'); + let carry = rawParts.pop() ?? ''; + const lines = rawParts.map((part) => part.trim()).filter((part) => part.length > 0); + const trimmedCarry = carry.trim(); + if (trimmedCarry.length > 0) { + try { + JSON.parse(trimmedCarry); + lines.push(trimmedCarry); + carry = ''; + } catch { + carry = trimmedCarry; + } + } else { + carry = ''; + } + return { lines, carry }; +} + +async function readAppendedChunk(filePath: string, start: number, end: number): Promise { + if (end <= start) return ''; + const length = end - start; + const handle = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + return buffer.toString('utf8'); + } finally { + await handle.close().catch(() => undefined); + } +} + +function pushBoundedSetValue(set: Set, value: string, limit: number): void { + if (set.has(value)) { + set.delete(value); + } + set.add(value); + while (set.size > limit) { + const oldest = set.values().next().value; + if (!oldest) break; + set.delete(oldest); + } +} diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts new file mode 100644 index 00000000..0e573e5e --- /dev/null +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -0,0 +1,140 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + normalizePersistedTaskChangePresenceIndex, + toPersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheSchema'; +import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; + +import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; + +const logger = createLogger('Service:JsonTaskChangePresenceRepository'); + +const READ_TIMEOUT_MS = 5_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository { + private readonly writeChains = new Map>(); + + private get basePath(): string { + return getTaskChangePresenceBasePath(); + } + + private filePath(teamName: string): string { + return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`); + } + + private async readIndex(teamName: string): Promise { + const filePath = this.filePath(teamName); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.promises.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.warn(`Failed to read task-change presence index ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`); + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + const normalized = normalizePersistedTaskChangePresenceIndex(parsed); + if (!normalized) { + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + return normalized; + } + + async load(teamName: string): Promise { + return this.readIndex(teamName); + } + + async upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: 'has_changes' | 'no_changes'; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise { + const write = async (): Promise => { + const current = + (await this.readIndex(teamName)) ?? + ({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: {}, + } satisfies PersistedTaskChangePresenceIndex); + + const next = toPersistedTaskChangePresenceIndex({ + ...current, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: { + ...current.entries, + [entry.taskId]: { + taskId: entry.taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + }, + }); + + await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2)); + }; + + const previous = this.writeChains.get(teamName) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(write) + .finally(() => { + if (this.writeChains.get(teamName) === next) { + this.writeChains.delete(teamName); + } + }); + + this.writeChains.set(teamName, next); + await next; + } +} diff --git a/src/main/services/team/cache/TaskChangePresenceRepository.ts b/src/main/services/team/cache/TaskChangePresenceRepository.ts new file mode 100644 index 00000000..e07910fa --- /dev/null +++ b/src/main/services/team/cache/TaskChangePresenceRepository.ts @@ -0,0 +1,23 @@ +import type { + PersistedTaskChangePresence, + PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; + +export interface TaskChangePresenceRepository { + load(teamName: string): Promise; + upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts new file mode 100644 index 00000000..b65af3e8 --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -0,0 +1,107 @@ +import { + type PersistedTaskChangePresence, + type PersistedTaskChangePresenceEntry, + type PersistedTaskChangePresenceIndex, + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, +} from './taskChangePresenceCacheTypes'; + +function isIsoString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +function normalizePresence(value: unknown): PersistedTaskChangePresence | null { + return value === 'has_changes' || value === 'no_changes' ? value : null; +} + +function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + const normalizedPresence = normalizePresence(raw.presence); + if ( + typeof raw.taskSignature !== 'string' || + !normalizedPresence || + !isIsoString(raw.writtenAt) || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 + ) { + return null; + } + + return { + taskId, + taskSignature: raw.taskSignature, + presence: normalizedPresence, + writtenAt: raw.writtenAt, + logSourceGeneration: raw.logSourceGeneration, + }; +} + +export function normalizePersistedTaskChangePresenceIndex( + value: unknown +): PersistedTaskChangePresenceIndex | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if ( + raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION || + typeof raw.teamName !== 'string' || + typeof raw.projectFingerprint !== 'string' || + raw.projectFingerprint.length === 0 || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 || + !isIsoString(raw.writtenAt) || + !raw.entries || + typeof raw.entries !== 'object' + ) { + return null; + } + + const normalizedEntries: Record = {}; + for (const [taskId, entryValue] of Object.entries(raw.entries as Record)) { + if (typeof taskId !== 'string' || taskId.length === 0) { + continue; + } + const normalized = normalizeEntry(taskId, entryValue); + if (normalized) { + normalizedEntries[taskId] = normalized; + } + } + + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: raw.teamName, + projectFingerprint: raw.projectFingerprint, + logSourceGeneration: raw.logSourceGeneration, + writtenAt: raw.writtenAt, + entries: normalizedEntries, + }; +} + +export function toPersistedTaskChangePresenceIndex( + value: PersistedTaskChangePresenceIndex +): PersistedTaskChangePresenceIndex { + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: value.teamName, + projectFingerprint: value.projectFingerprint, + logSourceGeneration: value.logSourceGeneration, + writtenAt: value.writtenAt, + entries: Object.fromEntries( + Object.entries(value.entries).map(([taskId, entry]) => [ + taskId, + { + taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + ]) + ), + }; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts new file mode 100644 index 00000000..f06f853f --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts @@ -0,0 +1,22 @@ +import type { TaskChangePresenceState } from '@shared/types/team'; + +export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; + +export type PersistedTaskChangePresence = Exclude; + +export interface PersistedTaskChangePresenceEntry { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; +} + +export interface PersistedTaskChangePresenceIndex { + version: typeof TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION; + teamName: string; + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + entries: Record; +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 7cfb94e8..ddc17421 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,3 +1,4 @@ +export { BranchStatusService } from './BranchStatusService'; export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; @@ -16,6 +17,8 @@ export { TeamDataService } from './TeamDataService'; export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; +export { TeamLogSourceTracker } from './TeamLogSourceTracker'; +export { TeammateToolTracker } from './TeammateToolTracker'; export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts new file mode 100644 index 00000000..0124f395 --- /dev/null +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -0,0 +1,197 @@ +import { isParsedSystemChunkMessage, isParsedUserChunkMessage, isTextContent } from '@main/types'; +import { parseJsonlLine } from '@main/utils/jsonl'; +import { extractCommandOutputInfo, extractSlashInfo } from '@shared/utils/contentSanitizer'; +import { buildSlashCommandMeta } from '@shared/utils/slashCommands'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; + +import type { ParsedMessage } from '@main/types'; +import type { CommandOutputMeta, InboxMessage, SlashCommandMeta } from '@shared/types'; + +const MAX_SCAN_BYTES = 8 * 1024 * 1024; +const INITIAL_SCAN_BYTES = 256 * 1024; + +interface LeadSessionMessageExtractorOptions { + jsonlPath: string; + leadName: string; + leadSessionId: string; + maxMessages: number; +} + +function getMessageText(message: ParsedMessage): string { + if (typeof message.content === 'string') { + return message.content.trim(); + } + + if (!Array.isArray(message.content)) { + return ''; + } + + return message.content + .filter(isTextContent) + .map((block) => block.text) + .join('\n') + .trim(); +} + +function buildScanKey(message: ParsedMessage, rawLine: string): string { + if (typeof message.uuid === 'string' && message.uuid.trim()) { + return message.uuid.trim(); + } + + return `${message.timestamp.toISOString()}\0${rawLine}`; +} + +function summarizeCommandOutput(output: string): string { + const firstLine = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) return ''; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + +function buildSlashMetaFromParsedMessage(message: ParsedMessage): SlashCommandMeta | null { + const slash = extractSlashInfo(getMessageText(message)); + if (!slash) return null; + return buildSlashCommandMeta(slash.name, slash.args, `/${slash.name}`); +} + +function buildCommandOutputMeta( + pendingSlash: SlashCommandMeta | null, + stream: CommandOutputMeta['stream'] +): CommandOutputMeta { + return { + stream, + commandLabel: pendingSlash?.command ?? '/command', + }; +} + +function buildResultMessageId(message: ParsedMessage, output: string): string { + const uuid = typeof message.uuid === 'string' ? message.uuid.trim() : ''; + if (uuid) { + return `lead-command-result-${uuid}`; + } + + return `lead-command-result-${createHash('sha256').update(`${message.timestamp.toISOString()}\n${output}`).digest('hex').slice(0, 16)}`; +} + +function canMergeCommandOutput( + previousMessage: InboxMessage | undefined, + commandOutput: CommandOutputMeta, + previousWasCommandOutput: boolean +): previousMessage is InboxMessage & { commandOutput: CommandOutputMeta } { + if (!previousWasCommandOutput || !previousMessage?.commandOutput) { + return false; + } + + return ( + previousMessage.messageKind === 'slash_command_result' && + previousMessage.commandOutput.stream === commandOutput.stream && + previousMessage.commandOutput.commandLabel === commandOutput.commandLabel + ); +} + +export async function extractLeadSessionMessagesFromJsonl({ + jsonlPath, + leadName, + leadSessionId, + maxMessages, +}: LeadSessionMessageExtractorOptions): Promise { + if (maxMessages <= 0) return []; + + const parsedMessagesReversed: ParsedMessage[] = []; + const seenScanKeys = new Set(); + const handle = await fs.promises.open(jsonlPath, 'r'); + + try { + const stat = await handle.stat(); + const fileSize = stat.size; + + let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); + while (scanBytes <= MAX_SCAN_BYTES) { + const start = Math.max(0, fileSize - scanBytes); + const buffer = Buffer.alloc(scanBytes); + await handle.read(buffer, 0, scanBytes, start); + const chunk = buffer.toString('utf8'); + + const lines = chunk.split(/\r?\n/); + const fromIndex = start > 0 ? 1 : 0; + + for (let i = lines.length - 1; i >= fromIndex; i--) { + const trimmed = lines[i]?.trim(); + if (!trimmed) continue; + + let parsed: ParsedMessage | null = null; + try { + parsed = parseJsonlLine(trimmed); + } catch { + parsed = null; + } + if (!parsed || parsed.isSidechain) continue; + + const scanKey = buildScanKey(parsed, trimmed); + if (seenScanKeys.has(scanKey)) continue; + seenScanKeys.add(scanKey); + parsedMessagesReversed.push(parsed); + } + + if (scanBytes === fileSize) break; + scanBytes = Math.min(fileSize, scanBytes * 2); + } + } finally { + await handle.close(); + } + + const parsedMessages = parsedMessagesReversed.reverse(); + const extractedMessages: InboxMessage[] = []; + let pendingSlash: SlashCommandMeta | null = null; + let previousWasCommandOutput = false; + + for (const message of parsedMessages) { + if (isParsedUserChunkMessage(message)) { + pendingSlash = buildSlashMetaFromParsedMessage(message); + previousWasCommandOutput = false; + continue; + } + + if (!isParsedSystemChunkMessage(message)) { + previousWasCommandOutput = false; + continue; + } + + const outputInfo = extractCommandOutputInfo(getMessageText(message)); + if (!outputInfo?.output) { + previousWasCommandOutput = false; + continue; + } + + const commandOutput = buildCommandOutputMeta(pendingSlash, outputInfo.stream); + const previousMessage = extractedMessages[extractedMessages.length - 1]; + if (canMergeCommandOutput(previousMessage, commandOutput, previousWasCommandOutput)) { + previousMessage.text = `${previousMessage.text}\n${outputInfo.output}`; + previousMessage.summary = summarizeCommandOutput(previousMessage.text) || undefined; + previousWasCommandOutput = true; + continue; + } + + extractedMessages.push({ + from: leadName, + text: outputInfo.output, + timestamp: message.timestamp.toISOString(), + read: true, + source: 'lead_session', + leadSessionId, + messageId: buildResultMessageId(message, outputInfo.output), + messageKind: 'slash_command_result', + commandOutput, + summary: summarizeCommandOutput(outputInfo.output) || undefined, + }); + previousWasCommandOutput = true; + } + + return extractedMessages.length > maxMessages + ? extractedMessages.slice(-maxMessages) + : extractedMessages; +} diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts new file mode 100644 index 00000000..3ef3b3c8 --- /dev/null +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -0,0 +1,163 @@ +import { + getTaskChangeStateBucket, + type TaskChangeStateBucket, +} from '@shared/utils/taskChangeState'; +import { deriveTaskSince } from '@shared/utils/taskChangeSince'; +import { createHash } from 'crypto'; + +export interface TaskChangePresenceInterval { + startedAt: string; + completedAt?: string; +} + +export interface TaskChangePresenceDescriptorInput { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + createdAt?: string; + since?: string; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangePresenceDescriptor { + stateBucket: TaskChangeStateBucket; + taskSignature: string; + effectiveOptions: { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + since?: string; + }; +} + +function deriveIntervalsFromHistory( + historyEvents?: unknown[] +): TaskChangePresenceInterval[] | undefined { + if (!Array.isArray(historyEvents) || historyEvents.length === 0) { + return undefined; + } + + const transitions = historyEvents + .map((event) => + event && typeof event === 'object' ? (event as Record) : null + ) + .filter((event): event is Record => event !== null) + .filter((event) => event.type === 'status_changed') + .map((event) => ({ + to: typeof event.to === 'string' ? event.to : null, + timestamp: typeof event.timestamp === 'string' ? event.timestamp : null, + })) + .filter( + (transition): transition is { to: string; timestamp: string } => + transition.to !== null && transition.timestamp !== null + ) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + if (transitions.length === 0) { + return undefined; + } + + const derived: TaskChangePresenceInterval[] = []; + let currentStart: string | null = null; + + for (const transition of transitions) { + if (transition.to === 'in_progress') { + if (!currentStart) { + currentStart = transition.timestamp; + } + continue; + } + + if (currentStart) { + derived.push({ startedAt: currentStart, completedAt: transition.timestamp }); + currentStart = null; + } + } + + if (currentStart) { + derived.push({ startedAt: currentStart }); + } + + return derived.length > 0 ? derived : undefined; +} + +export function normalizeTaskChangePresenceFilePath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); +} + +export function computeTaskChangePresenceProjectFingerprint( + projectPath?: string | null +): string | null { + const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : ''; + if (!normalizedProjectPath) { + return null; + } + + return createHash('sha256') + .update(normalizeTaskChangePresenceFilePath(normalizedProjectPath)) + .digest('hex'); +} + +export function buildTaskChangePresenceDescriptor( + input: TaskChangePresenceDescriptorInput +): TaskChangePresenceDescriptor { + const effectiveSince = + typeof input.since === 'string' + ? input.since + : deriveTaskSince({ + createdAt: input.createdAt, + workIntervals: input.intervals, + historyEvents: input.historyEvents as { timestamp?: string | null }[] | undefined, + }); + + const effectiveIntervals = + Array.isArray(input.intervals) && input.intervals.length > 0 + ? input.intervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) + : (deriveIntervalsFromHistory(input.historyEvents)?.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) ?? []); + + const stateBucket = getTaskChangeStateBucket({ + status: input.status, + reviewState: input.reviewState, + historyEvents: input.historyEvents, + kanbanColumn: input.kanbanColumn, + }); + + const effectiveOptions = { + owner: typeof input.owner === 'string' ? input.owner.trim() : '', + status: typeof input.status === 'string' ? input.status.trim() : '', + intervals: effectiveIntervals, + since: effectiveSince ?? '', + }; + + return { + stateBucket, + taskSignature: JSON.stringify({ + owner: effectiveOptions.owner, + status: effectiveOptions.status, + since: effectiveOptions.since, + stateBucket, + intervals: effectiveIntervals, + }), + effectiveOptions: { + owner: effectiveOptions.owner || undefined, + status: effectiveOptions.status || undefined, + intervals: + effectiveIntervals.length > 0 + ? effectiveIntervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt || undefined, + })) + : undefined, + since: effectiveOptions.since || undefined, + }, + }; +} diff --git a/src/main/services/team/taskChangeWorkerTypes.ts b/src/main/services/team/taskChangeWorkerTypes.ts new file mode 100644 index 00000000..a1aae4fe --- /dev/null +++ b/src/main/services/team/taskChangeWorkerTypes.ts @@ -0,0 +1,50 @@ +import type { TaskChangeSetV2 } from '@shared/types'; + +export interface TaskChangeTaskMeta { + createdAt?: string; + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangeEffectiveOptions { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; +} + +export interface ResolvedTaskChangeComputeInput { + teamName: string; + taskId: string; + taskMeta: TaskChangeTaskMeta | null; + effectiveOptions: TaskChangeEffectiveOptions; + projectPath?: string; + includeDetails: boolean; +} + +export interface ComputeTaskChangesRequest { + id: string; + op: 'computeTaskChanges'; + payload: ResolvedTaskChangeComputeInput; +} + +export interface ComputeTaskChangesSuccessResponse { + id: string; + ok: true; + result: TaskChangeSetV2; +} + +export interface ComputeTaskChangesErrorResponse { + id: string; + ok: false; + error: string; +} + +export type TaskChangeWorkerRequest = ComputeTaskChangesRequest; +export type TaskChangeWorkerResponse = + | ComputeTaskChangesSuccessResponse + | ComputeTaskChangesErrorResponse; diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 7a8c537b..79f6652e 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -103,6 +103,8 @@ export interface ParsedMessage { toolUseResult?: ToolUseResultData; /** Whether this is a compact summary boundary message */ isCompactSummary?: boolean; + /** API request ID for deduplicating streaming entries */ + requestId?: string; } // ============================================================================= diff --git a/src/main/utils/cliEnv.ts b/src/main/utils/cliEnv.ts index 6ad5ca15..b39d76ed 100644 --- a/src/main/utils/cliEnv.ts +++ b/src/main/utils/cliEnv.ts @@ -9,7 +9,7 @@ */ import { buildMergedCliPath } from '@main/utils/cliPathMerge'; -import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; import { userInfo } from 'os'; @@ -29,13 +29,20 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv osUsername || ''; + // Only set CLAUDE_CONFIG_DIR when the user has configured a custom path. + // Setting it to the default ~/.claude changes the macOS Keychain namespace + // that the CLI uses for OAuth credential lookup, causing "not logged in" + // even though `claude auth login` succeeded without the env var. + const configDir = getClaudeBasePath(); + const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath(); + return { ...process.env, ...(shellEnv ?? {}), HOME: home, USERPROFILE: home, PATH: buildMergedCliPath(binaryPath), - CLAUDE_CONFIG_DIR: getClaudeBasePath(), + ...(isCustomConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {}), ...(user ? { USER: user, diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 653f7755..59fe25d5 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -125,6 +125,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let role: string | undefined; let usage: TokenUsage | undefined; let model: string | undefined; + let requestId: string | undefined; let cwd: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; @@ -163,6 +164,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { usage = entry.message.usage; model = entry.message.model; agentId = entry.agentId; + requestId = entry.requestId; } else if (entry.type === 'system') { isMeta = entry.isMeta ?? false; } @@ -195,6 +197,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { sourceToolUseID, sourceToolAssistantUUID, toolUseResult, + requestId, }; } @@ -221,24 +224,61 @@ function parseMessageType(type?: string): MessageType | null { } } +// ============================================================================= +// Streaming Deduplication +// ============================================================================= + +/** + * Deduplicate streaming assistant entries by requestId. + * + * Claude Code writes multiple JSONL entries per API response during streaming, + * each with the same requestId but incrementally increasing output_tokens. + * Only the last entry per requestId has the final, complete token counts. + * + * Messages without a requestId (user, system, etc.) pass through unchanged. + * Returns a new array with only the last entry per requestId kept. + */ +export function deduplicateByRequestId(messages: ParsedMessage[]): ParsedMessage[] { + const lastIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const rid = messages[i].requestId; + if (rid) { + lastIndexByRequestId.set(rid, i); + } + } + + if (lastIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((msg, i) => { + if (!msg.requestId) return true; + return lastIndexByRequestId.get(msg.requestId) === i; + }); +} + // ============================================================================= // Metrics Calculation // ============================================================================= /** * Calculate session metrics from parsed messages. + * Deduplicates streaming entries by requestId before summing to avoid ~2x cost overcounting. */ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { if (messages.length === 0) { return { ...EMPTY_METRICS }; } + // Deduplicate streaming entries: keep only the last entry per requestId + const dedupedMessages = deduplicateByRequestId(messages); + let inputTokens = 0; let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) + // Get timestamps for duration from ALL messages (not deduped) for accurate session length const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); let minTime = 0; @@ -255,7 +295,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { // Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals) let costUsd = 0; - for (const msg of messages) { + for (const msg of dedupedMessages) { if (msg.usage) { const msgInputTokens = msg.usage.input_tokens ?? 0; const msgOutputTokens = msg.usage.output_tokens ?? 0; diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index f861401e..f28146ff 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -9,11 +9,24 @@ import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; +import { translateWslMountPath } from './pathDecoder'; + import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { Readable } from 'stream'; const logger = createLogger('Util:metadataExtraction'); +/** + * Normalize Windows drive letter to uppercase for consistent path comparison. + * CLI uses uppercase (C:\...) while VS Code extension uses lowercase (c:\...). + */ +function normalizeDriveLetter(p: string): string { + if (p.length >= 2 && p[1] === ':') { + return p[0].toUpperCase() + p.slice(1); + } + return p; +} + const defaultProvider = new LocalFileSystemProvider(); const JSONL_HEAD_TIMEOUT_MS = 2000; @@ -100,7 +113,7 @@ export async function extractCwd( } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { - return entry.cwd; + return normalizeDriveLetter(translateWslMountPath(entry.cwd)); } } } catch (error) { diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 5b04b068..6c221a13 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -69,7 +69,10 @@ export function decodePath(encodedName: string): string { } // Ensure leading slash for POSIX-style absolute paths - return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + const absolutePath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + + // Translate WSL mount paths to Windows drive-letter paths on Windows + return translateWslMountPath(absolutePath); } /** @@ -91,6 +94,23 @@ export function extractProjectName(encodedName: string, cwdHint?: string): strin return segments[segments.length - 1] || encodedName; } +/** + * Translate WSL mount paths (/mnt/X/...) to Windows drive-letter paths (X:/...) + * when running on Windows. No-op on other platforms. + */ +export function translateWslMountPath(posixPath: string): string { + if (process.platform !== 'win32') { + return posixPath; + } + const match = /^\/mnt\/([a-zA-Z])(\/.*)?$/.exec(posixPath); + if (match) { + const drive = match[1].toUpperCase(); + const rest = match[2] ?? ''; + return `${drive}:${rest}`; + } + return posixPath; +} + // ============================================================================= // Validation // ============================================================================= @@ -373,6 +393,10 @@ export function getTaskChangeSummariesBasePath(): string { return path.join(getClaudeBasePath(), 'task-change-summaries'); } +export function getTaskChangePresenceBasePath(): string { + return path.join(getClaudeBasePath(), 'task-change-presence'); +} + /** * Get the backups directory path for the app's own storage. */ @@ -392,8 +416,8 @@ export function getAppDataPath(): string { let appDataBasePathOverride: string | null = null; -export function setAppDataBasePath(p: string): void { - appDataBasePathOverride = p; +export function setAppDataBasePath(p: string | null | undefined): void { + appDataBasePathOverride = p ?? null; } function getAppDataBasePath(): string { @@ -408,3 +432,21 @@ function getAppDataBasePath(): string { return path.join(getHomeDir(), '.claude-agent-teams-ui'); } } + +/** + * Directory for per-team MCP config JSON files. + * Stored in app's userData so they persist across sessions and are + * accessible by Claude CLI subprocess on all platforms (including AppImage). + */ +export function getMcpConfigsBasePath(): string { + return path.join(getAppDataBasePath(), 'mcp-configs'); +} + +/** + * Directory for the stable MCP server bundle copy (packaged builds). + * Versioned subdirectories contain the copied index.js + package.json + * so the server runs from a writable, non-FUSE location. + */ +export function getMcpServerBasePath(): string { + return path.join(getAppDataBasePath(), 'mcp-server'); +} diff --git a/src/main/utils/safeWebContentsSend.ts b/src/main/utils/safeWebContentsSend.ts new file mode 100644 index 00000000..52d118ee --- /dev/null +++ b/src/main/utils/safeWebContentsSend.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { BrowserWindow } from 'electron'; + +const logger = createLogger('safeWebContentsSend'); +const rendererAvailability = new WeakMap(); + +export function markRendererReady(window: BrowserWindow | null | undefined): void { + if (!window || window.isDestroyed()) { + return; + } + rendererAvailability.set(window, true); +} + +export function markRendererUnavailable(window: BrowserWindow | null | undefined): void { + if (!window) { + return; + } + rendererAvailability.set(window, false); +} + +export function clearRendererAvailability(window: BrowserWindow | null | undefined): void { + if (!window) { + return; + } + rendererAvailability.delete(window); +} + +export function safeSendToRenderer( + window: BrowserWindow | null | undefined, + channel: string, + ...args: unknown[] +): boolean { + if (!window || window.isDestroyed()) { + return false; + } + + const contents = window.webContents; + if (!contents || contents.isDestroyed()) { + return false; + } + if (rendererAvailability.get(window) === false) { + return false; + } + + try { + contents.send(channel, ...args); + return true; + } catch (error) { + rendererAvailability.set(window, false); + logger.warn(`Failed to send "${channel}" to renderer: ${String(error)}`); + return false; + } +} diff --git a/src/main/workers/task-change-worker.ts b/src/main/workers/task-change-worker.ts new file mode 100644 index 00000000..77a6b119 --- /dev/null +++ b/src/main/workers/task-change-worker.ts @@ -0,0 +1,40 @@ +import { parentPort } from 'node:worker_threads'; + +import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser'; +import { TaskChangeComputer } from '@main/services/team/TaskChangeComputer'; +import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; + +import type { + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from '@main/services/team/taskChangeWorkerTypes'; + +const logsFinder = new TeamMemberLogsFinder(); +const boundaryParser = new TaskBoundaryParser(); +const computer = new TaskChangeComputer(logsFinder, boundaryParser); + +function postMessage(message: TaskChangeWorkerResponse): void { + parentPort?.postMessage(message); +} + +parentPort?.on('message', async (message: TaskChangeWorkerRequest) => { + if (message?.op !== 'computeTaskChanges') { + postMessage({ + id: message?.id ?? 'unknown', + ok: false, + error: `Unsupported task change worker op: ${String(message?.op)}`, + }); + return; + } + + try { + const result = await computer.computeTaskChanges(message.payload); + postMessage({ id: message.id, ok: true, result }); + } catch (error) { + postMessage({ + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 19131baf..509321c7 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,15 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get lightweight task change presence map for the currently viewed team */ +export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence'; + +/** Enable or disable task change presence tracking for a visible team tab */ +export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking'; + +/** Enable or disable live teammate tool activity tracking for a visible team tab */ +export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; @@ -298,6 +307,9 @@ export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats'; /** Start a pending task (transition to in_progress + notify agent) */ export const TEAM_START_TASK = 'team:startTask'; +/** Start a pending task from UI — always notifies owner (including lead in solo teams) */ +export const TEAM_START_TASK_BY_USER = 'team:startTaskByUser'; + /** Get all tasks across all teams */ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks'; @@ -307,6 +319,12 @@ export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment'; /** Get current git branch for a project path (live read from .git/HEAD) */ export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch'; +/** Enable or disable background tracking for a project path's git branch */ +export const TEAM_SET_PROJECT_BRANCH_TRACKING = 'team:setProjectBranchTracking'; + +/** Push event: tracked project branch changed (main → renderer) */ +export const TEAM_PROJECT_BRANCH_CHANGE = 'team:projectBranchChange'; + /** Add a new member to an existing team */ export const TEAM_ADD_MEMBER = 'team:addMember'; @@ -405,6 +423,9 @@ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; /** CLI installer progress events (main -> renderer) */ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; +/** Invalidate cached CLI status (forces fresh check on next getStatus) */ +export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; + // ============================================================================= // Terminal API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 3aa647db..f5ab238a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { APP_RELAUNCH, CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, + CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, @@ -127,6 +128,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -137,6 +139,7 @@ import { TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, TEAM_PROCESS_SEND, + TEAM_PROJECT_BRANCH_CHANGE, TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, @@ -147,10 +150,14 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_EVENT, TEAM_TOOL_APPROVAL_READ_FILE, @@ -243,6 +250,7 @@ import type { MemberLogSummary, MemberSpawnStatusesSnapshot, NotificationTrigger, + ProjectBranchChangeEvent, RejectResult, ReplaceMembersRequest, Schedule, @@ -258,6 +266,7 @@ import type { SshConnectionStatus, SshLastConnection, TaskAttachmentMeta, + TaskChangePresenceState, TaskChangeSetV2, TaskComment, TeamChangeEvent, @@ -798,6 +807,18 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getTaskChangePresence: async (teamName: string) => { + return invokeIpcWithResult>( + TEAM_GET_TASK_CHANGE_PRESENCE, + teamName + ); + }, + setChangePresenceTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); + }, + setToolActivityTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled); + }, getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); }, @@ -871,6 +892,13 @@ const electronAPI: ElectronAPI = { startTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); }, + startTaskByUser: async (teamName: string, taskId: string) => { + return invokeIpcWithResult<{ notifiedOwner: boolean }>( + TEAM_START_TASK_BY_USER, + teamName, + taskId + ); + }, processSend: async (teamName: string, message: string) => { return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); }, @@ -933,6 +961,9 @@ const electronAPI: ElectronAPI = { getProjectBranch: async (projectPath: string) => { return invokeIpcWithResult(TEAM_GET_PROJECT_BRANCH, projectPath); }, + setProjectBranchTracking: async (projectPath: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_PROJECT_BRANCH_TRACKING, projectPath, enabled); + }, getAttachments: async (teamName: string, messageId: string) => { return invokeIpcWithResult(TEAM_GET_ATTACHMENTS, teamName, messageId); }, @@ -1041,6 +1072,20 @@ const electronAPI: ElectronAPI = { mimeType ); }, + onProjectBranchChange: ( + callback: (event: unknown, data: ProjectBranchChangeEvent) => void + ): (() => void) => { + ipcRenderer.on( + TEAM_PROJECT_BRANCH_CHANGE, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TEAM_PROJECT_BRANCH_CHANGE, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, @@ -1100,8 +1145,8 @@ const electronAPI: ElectronAPI = { ); }; }, - updateToolApprovalSettings: async (settings: ToolApprovalSettings) => { - return invokeIpcWithResult(TEAM_TOOL_APPROVAL_SETTINGS, settings); + updateToolApprovalSettings: async (teamName: string, settings: ToolApprovalSettings) => { + return invokeIpcWithResult(TEAM_TOOL_APPROVAL_SETTINGS, teamName, settings); }, readFileForToolApproval: async (filePath: string) => { return invokeIpcWithResult(TEAM_TOOL_APPROVAL_READ_FILE, filePath); @@ -1293,6 +1338,9 @@ const electronAPI: ElectronAPI = { install: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_INSTALL); }, + invalidateStatus: async (): Promise => { + return invokeIpcWithResult(CLI_INSTALLER_INVALIDATE_STATUS); + }, onProgress: (callback: (event: unknown, data: CliInstallerProgress) => void): (() => void) => { ipcRenderer.on( CLI_INSTALLER_PROGRESS, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index a73fd8cb..d24b976c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -668,6 +668,17 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getTaskChangePresence: async (): Promise< + Record + > => { + return {}; + }, + setChangePresenceTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, + setToolActivityTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getClaudeLogs: async ( _teamName: string, _query?: TeamClaudeLogsQuery @@ -756,6 +767,12 @@ export class HttpAPIClient implements ElectronAPI { startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => { throw new Error('Team start task is not available in browser mode'); }, + startTaskByUser: async ( + _teamName: string, + _taskId: string + ): Promise<{ notifiedOwner: boolean }> => { + throw new Error('Team start task by user is not available in browser mode'); + }, processSend: async (_teamName: string, _message: string): Promise => { throw new Error('Team process communication is not available in browser mode'); }, @@ -822,6 +839,9 @@ export class HttpAPIClient implements ElectronAPI { getProjectBranch: async (_projectPath: string): Promise => { return null; }, + setProjectBranchTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getAttachments: async ( _teamName: string, _messageId: string @@ -901,6 +921,9 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Task attachments are not available in browser mode'); }, + onProjectBranchChange: (): (() => void) => { + return () => {}; + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) @@ -1045,6 +1068,7 @@ export class HttpAPIClient implements ElectronAPI { install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, + invalidateStatus: async (): Promise => {}, onProgress: (): (() => void) => { return () => {}; }, diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index d4b25355..db710805 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -11,7 +11,7 @@ import { CopyButton } from '../common/CopyButton'; import { OngoingBanner } from '../common/OngoingIndicator'; import { createMarkdownComponents, markdownComponents } from './markdownComponents'; -import { createSearchContext } from './searchHighlightUtils'; +import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils'; import type { AIGroupLastOutput } from '@renderer/types/groups'; @@ -41,12 +41,16 @@ export const LastOutputDisplay = ({ isLastGroup = false, isSessionOngoing = false, }: Readonly): React.JSX.Element | null => { + // Only re-render if THIS AI group has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(aiGroupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 6cf59051..860a5dc3 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -21,6 +21,7 @@ import { CopyButton } from '../common/CopyButton'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; @@ -401,13 +402,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. [teams] ); - // Get search state for highlighting + // Get search state for highlighting — only re-render if THIS item has matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(groupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const hasImages = content.images.length > 0; diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx new file mode 100644 index 00000000..de6fb699 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -0,0 +1,57 @@ +/** + * CollapsibleOutputSection + * + * Reusable component that wraps tool output in a collapsed-by-default section. + * Shows a clickable header with label, StatusDot, and chevron toggle. + */ + +import React, { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +interface CollapsibleOutputSectionProps { + status: ItemStatus; + children: React.ReactNode; + /** Label shown in the header (default: "Output") */ + label?: string; +} + +export const CollapsibleOutputSection: React.FC = ({ + status, + children, + label = 'Output', +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 1be3f906..c0a06fcf 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,8 +6,9 @@ import React from 'react'; -import { type ItemStatus, StatusDot } from '../BaseItem'; +import { type ItemStatus } from '../BaseItem'; +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
- {/* Output Section */} + {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( -
-
- Output - -
-
- {renderOutput(linkedTool.result.content)} -
-
+ + {renderOutput(linkedTool.result.content)} + )} ); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index f0eeb688..4edd8c93 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { CodeBlockViewer } from '@renderer/components/chat/viewers'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => ? startLine + limit - 1 : undefined; + const isMarkdownFile = /\.mdx?$/i.test(filePath); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + return ( - +
+ {isMarkdownFile && ( +
+ + +
+ )} + {isMarkdownFile && viewMode === 'preview' ? ( + + ) : ( + + )} +
); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index d08d7005..14fba8aa 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); return (
diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts index 5c415dac..83f37b92 100644 --- a/src/renderer/components/chat/items/linkedTool/index.ts +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -4,6 +4,7 @@ * Exports all specialized tool viewer components. */ +export { CollapsibleOutputSection } from './CollapsibleOutputSection'; export { DefaultToolViewer } from './DefaultToolViewer'; export { EditToolViewer } from './EditToolViewer'; export { ReadToolViewer } from './ReadToolViewer'; diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 34684f96..39a15be5 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -8,6 +8,9 @@ import React from 'react'; import type { SearchMatch } from '@renderer/store/types'; +/** Stable empty array for item-scoped search selectors (avoids re-renders) */ +export const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; + // Highlight styles matching SearchHighlight.tsx const baseStyles: React.CSSProperties = { borderRadius: '0.125rem', diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 703c07d9..9636ff2c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -36,15 +36,15 @@ import { useShallow } from 'zustand/react/shallow'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; +import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; -import type { SearchMatch } from '@renderer/store/types'; - // ============================================================================= // Types // ============================================================================= @@ -72,7 +72,6 @@ interface MarkdownViewerProps { const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); -const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; const NOOP_TEAM_CLICK = (): void => undefined; // ============================================================================= @@ -535,12 +534,21 @@ function createViewerMarkdownComponents( const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { + const lang = codeClassName?.replace('language-', '') ?? ''; + const raw = typeof children === 'string' ? children : ''; + const text = raw.replace(/\n$/, ''); + const lines = text.split('\n'); return ( - {hl(children)} + {lines.map((line, i) => ( + + {hl(highlightLine(line, lang))} + {i < lines.length - 1 ? '\n' : null} + + ))} ); } @@ -704,13 +712,16 @@ export const MarkdownViewer: React.FC = ({ const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only subscribe to search store when itemId is provided + // Only re-render if THIS item has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: itemId ? s.searchQuery : '', - searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: itemId ? s.currentSearchIndex : -1, - })) + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index 2271d08b..e6e2f6b3 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -208,6 +208,200 @@ const KEYWORDS: Record> = { 'true', 'false', ]), + r: new Set([ + 'if', + 'else', + 'for', + 'while', + 'repeat', + 'function', + 'return', + 'next', + 'break', + 'in', + 'library', + 'require', + 'source', + 'TRUE', + 'FALSE', + 'NULL', + 'NA', + 'Inf', + 'NaN', + 'NA_integer_', + 'NA_real_', + 'NA_complex_', + 'NA_character_', + ]), + ruby: new Set([ + 'def', + 'class', + 'module', + 'end', + 'do', + 'if', + 'elsif', + 'else', + 'unless', + 'while', + 'until', + 'for', + 'in', + 'begin', + 'rescue', + 'ensure', + 'raise', + 'return', + 'yield', + 'block_given?', + 'require', + 'require_relative', + 'include', + 'extend', + 'attr_accessor', + 'attr_reader', + 'attr_writer', + 'self', + 'super', + 'nil', + 'true', + 'false', + 'and', + 'or', + 'not', + 'then', + 'when', + 'case', + 'lambda', + 'proc', + 'puts', + 'print', + ]), + php: new Set([ + 'function', + 'class', + 'interface', + 'trait', + 'extends', + 'implements', + 'namespace', + 'use', + 'public', + 'private', + 'protected', + 'static', + 'abstract', + 'final', + 'const', + 'var', + 'new', + 'return', + 'if', + 'elseif', + 'else', + 'for', + 'foreach', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'default', + 'try', + 'catch', + 'finally', + 'throw', + 'as', + 'echo', + 'print', + 'require', + 'require_once', + 'include', + 'include_once', + 'true', + 'false', + 'null', + 'array', + 'isset', + 'unset', + 'empty', + 'self', + 'this', + ]), + sql: new Set([ + 'SELECT', + 'FROM', + 'WHERE', + 'INSERT', + 'INTO', + 'UPDATE', + 'SET', + 'DELETE', + 'CREATE', + 'ALTER', + 'DROP', + 'TABLE', + 'INDEX', + 'VIEW', + 'DATABASE', + 'JOIN', + 'INNER', + 'LEFT', + 'RIGHT', + 'OUTER', + 'FULL', + 'CROSS', + 'ON', + 'AND', + 'OR', + 'NOT', + 'IN', + 'EXISTS', + 'BETWEEN', + 'LIKE', + 'IS', + 'NULL', + 'AS', + 'ORDER', + 'BY', + 'GROUP', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'UNION', + 'ALL', + 'DISTINCT', + 'COUNT', + 'SUM', + 'AVG', + 'MIN', + 'MAX', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'TRANSACTION', + 'PRIMARY', + 'KEY', + 'FOREIGN', + 'REFERENCES', + 'CONSTRAINT', + 'DEFAULT', + 'VALUES', + 'TRUE', + 'FALSE', + 'INTEGER', + 'VARCHAR', + 'TEXT', + 'BOOLEAN', + 'DATE', + 'TIMESTAMP', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords @@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell) - if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) { + // Check for comment (# style for Python/Shell/R/Ruby/PHP) + if ( + (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + remaining.startsWith('#') + ) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for comment (-- style for SQL) + if (language === 'sql' && remaining.startsWith('--')) { segments.push( React.createElement( 'span', @@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining); if (wordMatch) { const word = wordMatch[1]; - if (keywords.has(word)) { + // SQL keywords are case-insensitive + if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) { segments.push( React.createElement( 'span', diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index 04e9ec7d..7bd72fbd 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -48,6 +48,8 @@ interface TokenUsageDisplayProps { totalPhases?: number; /** Optional USD cost for this usage */ costUsd?: number; + /** Context window size (e.g., 200000 or 1000000). When provided, shows "X% context used" instead of "X% of input". */ + contextWindowSize?: number; } /** @@ -57,9 +59,11 @@ interface TokenUsageDisplayProps { const SessionContextSection = ({ contextStats, totalInputTokens, + contextWindowSize, }: Readonly<{ contextStats: ContextStats; totalInputTokens: number; + contextWindowSize?: number; }>): React.JSX.Element => { const [expanded, setExpanded] = useState(false); @@ -67,11 +71,15 @@ const SessionContextSection = ({ // contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files, // tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed. - // Denominator is total input tokens only (not output), since visible context is part of input. + // Show context window usage % when contextWindowSize is available (more useful), + // otherwise fall back to visible context / total input ratio. const contextPercent = - totalInputTokens > 0 - ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) - : '0.0'; + contextWindowSize && contextWindowSize > 0 + ? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1) + : totalInputTokens > 0 + ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) + : '0.0'; + const contextLabel = contextWindowSize ? 'of context' : 'of input'; // Count accumulated injections by category const claudeMdCount = contextStats.accumulatedInjections.filter( @@ -144,7 +152,7 @@ const SessionContextSection = ({ className="whitespace-nowrap text-[10px] tabular-nums" style={{ color: COLOR_TEXT_MUTED }} > - {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}%) + {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel})
@@ -253,6 +261,7 @@ export const TokenUsageDisplay = ({ phaseNumber, totalPhases, costUsd, + contextWindowSize, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; // Total input tokens only (without output) — used as denominator for visible context % @@ -531,6 +540,7 @@ export const TokenUsageDisplay = ({ )} diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index f2f09656..fa5b3465 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -81,6 +81,11 @@ export const UpdateDialog = (): React.JSX.Element | null => { const isDownloaded = updateStatus === 'downloaded'; + // Strip "Downloads" section (and everything after it) from release notes + const filteredNotes = releaseNotes + ? releaseNotes.replace(/\n#{1,3}\s+Downloads[\s\S]*$/i, '').trimEnd() + : releaseNotes; + const releaseUrl = availableVersion ? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}` : null; @@ -106,7 +111,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { />
{ {/* Release notes */}
- {releaseNotes ? ( + {filteredNotes ? ( - {releaseNotes} + {filteredNotes} ) : (

diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index b59ecc40..e19d3fe7 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -18,7 +18,10 @@ import { formatBytes } from '@renderer/utils/formatters'; import { AlertTriangle, CheckCircle, + ChevronDown, + ChevronUp, Download, + HelpCircle, Loader2, LogIn, Puzzle, @@ -272,11 +275,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => { installerRawChunks, completedVersion, fetchCliStatus, + invalidateCliStatus, installCli, isBusy, } = useCliInstaller(); const [showLoginTerminal, setShowLoginTerminal] = useState(false); + const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); + const [showTroubleshoot, setShowTroubleshoot] = useState(false); useEffect(() => { if (!isElectron) return; @@ -526,6 +532,22 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed but not logged in — yellow warning banner if (cliStatus.installed && !cliStatus.authLoggedIn) { + if (isVerifyingAuth) { + return ( +

+ +

+ Verifying authentication... +

+
+ ); + } return ( <>
{

- +
+ + +
+ + {showTroubleshoot && ( +
+

+ If you're sure you're logged in, try these steps: +

+
    +
  1. + Click{' '} + {' '} + — sometimes the status is cached for a few seconds +
  2. +
  3. + Open your terminal and run:{' '} + + claude auth status + {' '} + — check if it shows "Logged in" +
  4. +
  5. + If it says logged in but the app doesn't see it, try:{' '} + + claude auth logout + {' '} + then{' '} + + claude auth login + {' '} + again +
  6. +
  7. + Make sure{' '} + + claude + {' '} + in your terminal is the same binary the app uses + {cliStatus.binaryPath && ( + + :{' '} + + {cliStatus.binaryPath} + + + )} +
  8. +
+

+ Browsing sessions and projects works without login. Login is only needed to run + agent teams. +

+
+ )} {showLoginTerminal && cliStatus.binaryPath && ( { args={['auth', 'login']} onClose={() => { setShowLoginTerminal(false); - void fetchCliStatus(); + setIsVerifyingAuth(true); + void (async () => { + try { + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsVerifyingAuth(false); + } + })(); }} onExit={() => { - void fetchCliStatus(); + setIsVerifyingAuth(true); + void (async () => { + try { + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsVerifyingAuth(false); + } + })(); }} autoCloseOnSuccessMs={4000} successMessage="Login complete" diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 39931ad9..a7449d29 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -4,6 +4,7 @@ */ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; +import { TeamGraphTab } from '@renderer/features/agent-graph/ui/TeamGraphTab'; import { DashboardView } from '../dashboard/DashboardView'; import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; @@ -20,9 +21,10 @@ import type { Pane } from '@renderer/types/panes'; interface PaneContentProps { pane: Pane; + isPaneFocused: boolean; } -export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { +export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => { const activeTabId = pane.activeTabId; // Show default dashboard if no tabs are open in this pane @@ -50,7 +52,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'teams' && } {tab.type === 'team' && ( - + )} {tab.type === 'session' && ( @@ -65,6 +67,15 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} {tab.type === 'schedules' && } + {tab.type === 'graph' && ( + + + + )} ); })} diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index 29db5e24..51039bf3 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -49,7 +49,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { }} onMouseDown={handleMouseDown} > - + {/* Edge split drop zones - visible only during active drag when under MAX_PANES */} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 6c22d0e4..0f34ad2a 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -17,6 +17,7 @@ import { Calendar, FileText, LayoutDashboard, + Network, Pin, Puzzle, Search, @@ -52,6 +53,7 @@ const TAB_ICONS = { report: Activity, extensions: Puzzle, schedules: Calendar, + graph: Network, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index c1b99164..9e079e03 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -243,42 +243,53 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { isMacElectron && isLeftmostPane ? 'var(--macos-traffic-light-padding-left, 72px)' : '8px', - WebkitAppRegion: 'no-drag', + WebkitAppRegion: isMacElectron ? 'drag' : 'no-drag', opacity: isFocused || paneCount === 1 ? 1 : 0.7, } as React.CSSProperties } > - {/* Tab list with horizontal scroll, sortable DnD, and droppable area. - Capped at 75% so the drag spacer always has room to the right. */}
{ - scrollContainerRef.current = el; - setDroppableRef(el); - }} - className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" - style={{ - outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', - outlineOffset: '-1px', - overflowX: 'auto', - overflowY: 'hidden', - }} + className="flex min-w-0 shrink items-center gap-1" + style={ + { + WebkitAppRegion: 'no-drag', + flex: '0 1 auto', + maxWidth: 'calc(100% - 32px)', + } as React.CSSProperties + } > - - {openTabs.map((tab) => ( - - ))} - + {/* Keep the sortable list inside a no-drag group so tabs remain clickable, + while any leftover space in the pane segment can drag the window. */} +
{ + scrollContainerRef.current = el; + setDroppableRef(el); + }} + className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" + style={{ + outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', + outlineOffset: '-1px', + overflowX: 'auto', + overflowY: 'hidden', + }} + > + + {openTabs.map((tab) => ( + + ))} + +
{/* Refresh button - show only for session tabs */} {activeTab?.type === 'session' && ( @@ -298,6 +309,9 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )}
+ {/* Guaranteed drag target, even when the tab list is dense. */} +
+ {/* Context menu */} {contextMenu && contextMenuTabId && ( { + const messagesPanelMode = useStore((s) => s.messagesPanelMode); const [open, setOpen] = useState(false); const [hoveredId, setHoveredId] = useState(null); const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); + const visibleSections = SECTIONS.filter( + (section) => + messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') + ); const handleNavigate = useCallback( (sectionId: string) => { @@ -99,7 +105,7 @@ export const TeamTabSectionNav = ({ if (e.key === 'Escape') setOpen(false); }} > - {SECTIONS.map((section) => { + {visibleSections.map((section) => { const SectionIcon = section.icon; return ( {contextMenu && diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index d6db9f80..da3a6104 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -13,6 +13,8 @@ import { type TaskStatusFilterId, } from './taskFiltersState'; +import type { ComboboxOption } from '../ui/combobox'; + const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'unread', label: 'Unread' }, @@ -23,6 +25,7 @@ interface TaskFiltersPopoverProps { open: boolean; onOpenChange: (open: boolean) => void; teams: { teamName: string; displayName: string }[]; + projectOptions: ComboboxOption[]; filters: TaskFiltersState; onFiltersChange: (f: TaskFiltersState) => void; onApply: () => void; @@ -32,6 +35,7 @@ export const TaskFiltersPopover = ({ open, onOpenChange, teams, + projectOptions, filters, onFiltersChange, onApply, @@ -138,6 +142,25 @@ export const TaskFiltersPopover = ({ />
+ {projectOptions.length > 0 && ( +
+ + Project + + setDraft({ ...draft, projectPath: v || null })} + placeholder="All Projects" + searchPlaceholder="Search projects..." + emptyMessage="No projects" + className="text-[12px]" + resetLabel="All Projects" + onReset={() => setDraft({ ...draft, projectPath: null })} + /> +
+ )} +
Comments diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index c7f0cc59..1cc7a86e 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -25,6 +25,7 @@ export type ReadFilter = 'all' | 'unread' | 'read'; export interface TaskFiltersState { statusIds: Set; teamName: string | null; + projectPath: string | null; /** @deprecated Use readFilter instead */ unreadOnly: boolean; readFilter: ReadFilter; @@ -33,6 +34,7 @@ export interface TaskFiltersState { export const defaultTaskFiltersState = (): TaskFiltersState => ({ statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), teamName: null, + projectPath: null, unreadOnly: false, readFilter: 'all', }); diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index a6ceb73e..1c8b56c5 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react'; +import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react'; import { ClaudeLogsDialog } from './ClaudeLogsDialog'; import { ClaudeLogsPanel } from './ClaudeLogsPanel'; @@ -96,11 +96,7 @@ export const ClaudeLogsSection = ({ - - - } + icon={null} badge={ctrl.badge} afterBadge={ ctrl.data.total > 0 ? ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6605826..da7dec5a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -42,6 +42,7 @@ import { FolderOpen, GitBranch, History, + Network, Pencil, Play, Plus, @@ -71,10 +72,18 @@ import type { AddMemberEntry } from './dialogs/AddMemberDialog'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); +const TeamGraphOverlay = lazy(() => + import('@renderer/features/agent-graph/ui/TeamGraphOverlay').then((m) => ({ + default: m.TeamGraphOverlay, + })) +); import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ScheduleSection } from './schedule/ScheduleSection'; +import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; +import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; +import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; @@ -96,6 +105,7 @@ import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { teamName: string; + isPaneFocused?: boolean; } interface CreateTaskDialogState { @@ -172,7 +182,10 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask ); } -export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { +export const TeamDetailView = ({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element => { const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -192,20 +205,172 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [editDialogOpen, setEditDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); - // Set inert on background content when editor overlay is open (a11y focus trap) + // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { const el = contentRef.current; if (!el) return; - if (editorOpen) { + if (editorOpen || graphOpen) { el.setAttribute('inert', ''); } else { el.removeAttribute('inert'); } - }, [editorOpen]); + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + useStore.getState().openTab({ + type: 'graph', + label: `${teamName} Graph`, + teamName, + }); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [teamName]); + + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = data.members.find((m: { name: string }) => m.name === memberName); + if (member) setSelectedMember(member); + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); + + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } + } catch { + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -261,7 +426,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage, requestReview, createTeamTask, - startTask, + startTaskByUser, deleteTeam, openTeamsTab, closeTab, @@ -310,7 +475,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage: s.sendTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, - startTask: s.startTask, + startTaskByUser: s.startTaskByUser, deleteTeam: s.deleteTeam, openTeamsTab: s.openTeamsTab, closeTab: s.closeTab, @@ -612,6 +777,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele () => (teamProjectPath ? [teamProjectPath] : []), [teamProjectPath] ); + // Live branch sync now uses main-side background tracking instead of renderer polling. useBranchSync(branchSyncPaths, { live: true }); const leadBranch = useStore((s) => teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null @@ -1038,6 +1204,27 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : nameColorSet(data.config.name); + const sharedMessagesPanelProps = { + teamName, + onTogglePosition: toggleMessagesPanelMode, + members: activeMembers, + tasks: data.tasks, + messages: data.messages, + isTeamAlive: data.isAlive, + leadActivity: leadActivityByTeam[teamName], + leadContextUpdatedAt, + timeWindow, + teamSessionIds, + currentLeadSessionId: data.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: setSelectedMember, + onTaskClick: setSelectedTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + }; return ( <> @@ -1092,48 +1279,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele )} {/* Messages sidebar (left, after context panel) */} - {messagesPanelMode === 'sidebar' && ( -
+ -
-
- -
-
-
- -
-
- {/* Resize handle */} -
-
- )} + +
{ - e.stopPropagation(); - setAddMemberDialogOpen(true); - }} - > - - Member - +
+ + +
} > { void (async () => { try { - const result = await startTask(teamName, taskId); + const result = await startTaskByUser(teamName, taskId); if (data?.isAlive) { const task = data.tasks.find((t) => t.id === taskId); try { @@ -1678,28 +1865,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele {messagesPanelMode !== 'sidebar' && } {messagesPanelMode === 'inline' && ( - + )} )} + + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + /> + + )} ); }; diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index 13929447..7e19cb64 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; +import { formatSessionLabel } from '@renderer/utils/sessionTitleParser'; import { formatDistanceToNowStrict } from 'date-fns'; import { AlertCircle, @@ -69,7 +70,7 @@ export const TeamSessionsSection = ({ type: 'session', sessionId: session.id, projectId, - label: session.firstMessage?.slice(0, 50) ?? 'Session', + label: formatSessionLabel(session.firstMessage), }, { forceNewTab: true } ); @@ -173,7 +174,7 @@ const SessionRow = ({ onToggleFilter, }: SessionRowProps): React.JSX.Element => { const timeAgo = formatShortTime(new Date(session.createdAt)); - const label = session.firstMessage ?? 'Untitled session'; + const label = formatSessionLabel(session.firstMessage); return (
; + case 'AskUserQuestion': + return ; default: return ; } @@ -124,10 +154,13 @@ export const ToolApprovalSheet: React.FC = () => { const [disabled, setDisabled] = useState(false); const [error, setError] = useState(null); const [diffExpanded, setDiffExpanded] = useState(false); + const [settingsExpanded, setSettingsExpanded] = useState(false); + const [selectedOptions, setSelectedOptions] = useState>(new Set()); - // Clear error when current approval changes + // Clear error + selection when current approval changes useEffect(() => { setError(null); + setSelectedOptions(new Set()); }, [current?.requestId]); const handleRespond = useCallback( @@ -136,6 +169,28 @@ export const ToolApprovalSheet: React.FC = () => { setDisabled(true); setError(null); + // For AskUserQuestion, build per-question answers from selected options + // Key format in selectedOptions: "qi:label" — parse question index to map correctly + const answersMessage = + allow && current.toolName === 'AskUserQuestion' && selectedOptions.size > 0 + ? (() => { + const questions = Array.isArray(current.toolInput.questions) + ? (current.toolInput.questions as { question?: string }[]) + : []; + const answersByQuestion: Record = {}; + for (const key of selectedOptions) { + const colonIdx = key.indexOf(':'); + if (colonIdx < 0) continue; + const qi = parseInt(key.slice(0, colonIdx), 10); + const label = key.slice(colonIdx + 1); + const questionText = questions[qi]?.question ?? `Question ${qi + 1}`; + const existing = answersByQuestion[questionText]; + answersByQuestion[questionText] = existing ? `${existing}, ${label}` : label; + } + return JSON.stringify(answersByQuestion); + })() + : undefined; + // Safety timeout — if IPC hangs (e.g. stdin.write callback never fires), // re-enable the button so the user isn't stuck forever. const safetyTimer = setTimeout(() => { @@ -143,7 +198,13 @@ export const ToolApprovalSheet: React.FC = () => { setError('Response timed out — process may be unresponsive. Try again or stop the team.'); }, RESPOND_TIMEOUT_MS); - respondToToolApproval(current.teamName, current.runId, current.requestId, allow) + respondToToolApproval( + current.teamName, + current.runId, + current.requestId, + allow, + answersMessage + ) .then(() => { clearTimeout(safetyTimer); // Small delay before re-enabling to prevent accidental double-clicks @@ -156,14 +217,35 @@ export const ToolApprovalSheet: React.FC = () => { setDisabled(false); }); }, - [current, disabled, respondToToolApproval] + [current, disabled, respondToToolApproval, selectedOptions] ); + const isAskQuestion = current?.toolName === 'AskUserQuestion'; + const hasSelection = selectedOptions.size > 0; + + const handleOptionSelect = useCallback((label: string, multiSelect: boolean) => { + setSelectedOptions((prev) => { + // For single-select: clear all options from the SAME question (same prefix) + // Key format: "qi:label" where qi is the question index + const prefix = label.split(':')[0] + ':'; + const next = multiSelect + ? new Set(prev) + : new Set(Array.from(prev).filter((k) => !k.startsWith(prefix))); + if (next.has(label)) { + next.delete(label); + } else { + next.add(label); + } + return next; + }); + }, []); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'Enter') { + if (isAskQuestion && !hasSelection) return; e.preventDefault(); handleRespond(true); } else if (e.key === 'Escape') { @@ -174,167 +256,201 @@ export const ToolApprovalSheet: React.FC = () => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleRespond]); + }, [handleRespond, isAskQuestion, hasSelection]); + + // Resolve teammate color for MemberBadge (when source !== 'lead') + const sourceColor = useMemo(() => { + if (!current || current.source === 'lead') return undefined; + const member = selectedTeamData?.members?.find((m) => m.name === current.source); + return member?.color; + }, [current, selectedTeamData?.members]); if (!current) return null; - // Prefer color from the approval itself (always available, even during provisioning), - // fall back to teams list, then getTeamColorSet hashes unknown names into TEAMMATE_COLORS. const teamSummary = teams.find((t) => t.teamName === current.teamName); const colorName = current.teamColor ?? teamSummary?.color ?? current.teamName; const teamColor = getTeamColorSet(colorName); const displayName = current.teamDisplayName ?? teamSummary?.displayName ?? current.teamName; return ( -
- {/* Header */} + <> + {/* Backdrop overlay */} +
+
-
- {getToolIcon(current.toolName)} - - {current.toolName} - -
-
- {selectedTeamName !== current.teamName && ( - - {displayName} - - )} - -
-
- - {/* Tool input preview (syntax-highlighted) */} - - - {/* Diff preview (Write/Edit/NotebookEdit only) */} - - - {/* Error feedback */} - {error && ( + {/* Header */}
- - {error} +
+ {current.source !== 'lead' && ( + + )} + {getToolIcon(current.toolName)} + + {getToolDisplayName(current.toolName)} + +
+
+ {selectedTeamName !== current.teamName && ( + + {displayName} + + )} + +
- )} - {/* Actions */} -
-
- - - -
- - -
- {pendingApprovals.length > 1 && ( - - {pendingApprovals.length - 1} pending - + + {error} +
)} + + {/* Actions */} +
+
+ + + +
+ + +
+
+ {pendingApprovals.length > 1 && ( + + {pendingApprovals.length - 1} pending + + )} + setSettingsExpanded((v) => !v)} + /> +
+
+ + {/* Settings expanded content — below actions row */} + + + {/* Timeout progress bar */} +
- - {/* Settings panel (full-width, outside flex row) */} - - - {/* Timeout progress bar */} - -
+ ); }; @@ -348,10 +464,14 @@ const ToolInputPreview = ({ toolName, toolInput, projectPath, + selectedOptions, + onOptionSelect, }: { toolName: string; toolInput: Record; projectPath?: string; + selectedOptions?: Set; + onOptionSelect?: (label: string, multiSelect: boolean) => void; }): React.JSX.Element => { const text = renderToolInput(toolName, toolInput, projectPath); const fileName = getToolInputFileName(toolName, toolInput); @@ -359,6 +479,96 @@ const ToolInputPreview = ({ const rawFilePath = typeof toolInput.file_path === 'string' ? toolInput.file_path : null; const isFileTool = FILE_TOOLS.has(toolName) && rawFilePath; + // AskUserQuestion: render questions with options as readable UI + if (toolName === 'AskUserQuestion' && Array.isArray(toolInput.questions)) { + const questions = toolInput.questions as { + question?: string; + header?: string; + options?: { label?: string; description?: string }[]; + multiSelect?: boolean; + }[]; + return ( +
+ {questions.map((q, qi) => ( +
+ {q.header && ( + + {q.header} + + )} + {q.question && ( +

+ {q.question} +

+ )} + {Array.isArray(q.options) && ( +
+ {q.options.map((opt, oi) => { + const optKey = `${qi}:${opt.label ?? `opt-${oi}`}`; + const isSelected = selectedOptions?.has(optKey) ?? false; + return ( + + ); + })} +
+ )} +
+ ))} +
+ ); + } + return (
0 ? teamName : null; } +function getCommandOutputSummary(text: string): string { + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) return ''; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + export function isQualifiedExternalRecipient( value: string | undefined, teamName: string, @@ -222,6 +250,17 @@ function getNoiseLabel(parsed: StructuredMessage): string | null { : 'Completed a task'; } + if (type === 'permission_request') { + const toolName = getStringField(parsed, 'tool_name'); + return toolName ? `Permission: ${toolName}` : 'Permission request'; + } + + if (type === 'permission_response') { + if (parsed.approved === true) return 'Permission granted'; + if (parsed.approved === false) return 'Permission denied'; + return 'Permission response'; + } + return null; } @@ -233,10 +272,12 @@ const NoiseRow = ({ name, label, colors, + icon, }: { name: string; label: string; colors: TeamColorSet; + icon?: React.ReactNode; }): React.JSX.Element => (
@@ -246,6 +287,7 @@ const NoiseRow = ({ {label} + {icon}
); @@ -462,6 +504,8 @@ export const ActivityItem = memo( message.from === 'user' || message.from === 'system' || crossTeamOrigin?.memberName === 'user'; + const isUserSent = message.source === 'user_sent' || isCrossTeamSent; + const isSystemMessage = message.from === 'system'; // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -476,6 +520,28 @@ export const ActivityItem = memo( // Normalize literal \n from historical CLI-produced text to real newlines return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); }, [structured, message.text, isCrossTeamAny]); + const standaloneSlashCommand = useMemo( + () => (strippedText ? parseStandaloneSlashCommand(strippedText) : null), + [strippedText] + ); + const slashCommandMeta = useMemo( + () => + message.slashCommand ?? + (standaloneSlashCommand + ? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw) + : null), + [message.slashCommand, standaloneSlashCommand] + ); + const knownSlashCommand = useMemo( + () => (slashCommandMeta?.name ? (getKnownSlashCommand(slashCommandMeta.name) ?? null) : null), + [slashCommandMeta] + ); + const isSlashCommandResult = + message.messageKind === 'slash_command_result' && !!message.commandOutput; + const isSlashCommandMessage = + !isSlashCommandResult && + (message.messageKind === 'slash_command' || (isUserSent && standaloneSlashCommand !== null)); + const isCommandOutputError = isSlashCommandResult && message.commandOutput?.stream === 'stderr'; // Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name // into markdown links which breaks the reply regex matcher @@ -502,6 +568,16 @@ export const ActivityItem = memo( }, [isCrossTeamAny, strippedText]); const rawSummary = useMemo(() => { + if (isSlashCommandResult && message.commandOutput) { + return message.summary || getCommandOutputSummary(message.text); + } + if (isSlashCommandMessage && slashCommandMeta) { + if (slashCommandMeta.args) { + const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim(); + return `${slashCommandMeta.command} ${oneLine}`; + } + return slashCommandMeta.command; + } if (crossTeamPreview) return crossTeamPreview; const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; @@ -511,12 +587,49 @@ export const ActivityItem = memo( if (!plain) return ''; const oneLine = plain.replace(/\n+/g, ' '); return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; - }, [crossTeamPreview, message.summary, structured, message.text]); + }, [ + crossTeamPreview, + isSlashCommandMessage, + isSlashCommandResult, + message.commandOutput, + message.summary, + message.text, + slashCommandMeta, + standaloneSlashCommand, + structured, + ]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); + // Permission request status icon (check/x/clock) + const pendingApprovals = useStore((s) => s.pendingApprovals); + const resolvedApprovals = useStore((s) => s.resolvedApprovals); + const permissionIcon = useMemo(() => { + if (!structured) return null; + const type = typeof structured.type === 'string' ? structured.type : null; + if (type !== 'permission_request') return null; + const requestId = typeof structured.request_id === 'string' ? structured.request_id : null; + if (!requestId) return null; + + const resolved = resolvedApprovals.get(requestId); + if (resolved === true) { + return ; + } + if (resolved === false) { + return ; + } + const isPending = pendingApprovals.some((a) => a.requestId === requestId); + if (isPending) { + return ; + } + // Not in pending and not resolved — already handled before we started tracking + return ; + }, [structured, pendingApprovals, resolvedApprovals]); + // Noise messages: minimal inline row if (noiseLabel) { - return ; + return ( + + ); } const messageType = @@ -544,8 +657,6 @@ export const ActivityItem = memo( const isHeaderClickable = isManaged && canToggleCollapse; const showChevron = isHeaderClickable && !compactHeader; - const isUserSent = message.source === 'user_sent' || isCrossTeamSent; - const isSystemMessage = message.from === 'system'; const handleHeaderToggle = useCallback(() => { if (isHeaderClickable && collapseToggleKey) { onToggleCollapse?.(collapseToggleKey); @@ -556,33 +667,45 @@ export const ActivityItem = memo(
{/* Header — div with role=button (cannot use
+ ) : isSlashCommandResult && message.commandOutput ? ( +
+
+ + + {message.commandOutput.commandLabel} + + + {message.commandOutput.stream} + +
+ +
+
+ +
+                    {message.text}
+                  
+
+
+ ) : isSlashCommandMessage && slashCommandMeta ? ( +
+
+ + + {slashCommandMeta.command} + +
+ {(slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( +

+ {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} +

+ ) : null} + {slashCommandMeta.args ? ( +
+ {slashCommandMeta.args} +
+ ) : null} +
) : parsedReply ? ( 0) return false; // Compaction boundary events are system messages, not lead thoughts if (isCompactionMessage(msg)) return false; + if (msg.messageKind === 'slash_command_result') return false; // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden if (isThoughtProtocolNoise(msg.text)) return false; if (msg.source === 'lead_session') return true; @@ -68,6 +69,21 @@ export function isLeadThought(msg: InboxMessage): boolean { return false; } +/** + * Check if a message from lead session/process is protocol noise that should be + * completely excluded from the timeline (not shown as thoughts OR standalone messages). + * + * When `isLeadThought` returns false due to `isThoughtProtocolNoise`, the message + * falls through to become a standalone ActivityItem — but ActivityItem can't parse + * noise JSON wrapped in `` tags. This helper catches those cases + * so `groupTimelineItems` can skip them entirely. + */ +function isLeadSessionNoise(msg: InboxMessage): boolean { + if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false; + if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; + return isThoughtProtocolNoise(msg.text); +} + export type TimelineItem = | { type: 'message'; message: InboxMessage } | { type: 'lead-thoughts'; group: LeadThoughtGroup }; @@ -108,6 +124,12 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { } pendingThoughts.push(msg); } else { + // Skip lead session/process messages that are protocol noise — they should + // not appear in the timeline at all (neither as thoughts nor as standalone messages). + // isLeadThought already rejects these from thoughts, but without this guard + // they fall through as standalone ActivityItem cards that can't parse the noise JSON. + // Check BEFORE flushThoughts() so noise between two thoughts doesn't split the group. + if (isLeadSessionNoise(msg)) continue; flushThoughts(); result.push({ type: 'message', message: msg }); } diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 1d5f1bdc..1d2d7550 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -112,9 +112,7 @@ const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }, { name: 'tom', - roleSelection: 'researcher', - workflow: - 'Research topics, gather information, and analyze relevant sources. Investigate questions, explore options, and provide detailed findings with clear summaries for the team.', + roleSelection: 'developer', }, { name: 'bob', roleSelection: 'developer' }, { name: 'jack', roleSelection: 'developer' }, @@ -1144,8 +1142,15 @@ export const CreateTeamDialog = ({ ? 'Warming up CLI environment...' : 'Preparing environment...')} -

- Pre-flight check to catch errors before launch +

+ Pre-flight check to catch errors before launch +

diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 0d0327d9..2445a6c6 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -5,6 +5,10 @@ import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest import { ExternalLink } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { + hasSelectedTargetTeamData, + shouldKeepGlobalTaskDialogLoading, +} from './globalTaskDetailDialogLoading'; import { TaskDetailDialog } from './TaskDetailDialog'; import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; @@ -21,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { selectedTeamName, selectedTeamData, selectedTeamLoading, + selectedTeamError, selectTeam, openTeamTab, setPendingReviewRequest, @@ -32,6 +37,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, selectedTeamLoading: s.selectedTeamLoading, + selectedTeamError: s.selectedTeamError, selectTeam: s.selectTeam, openTeamTab: s.openTeamTab, setPendingReviewRequest: s.setPendingReviewRequest, @@ -41,6 +47,11 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { const teamName = globalTaskDetail?.teamName ?? ''; const taskId = globalTaskDetail?.taskId ?? ''; + const hasTargetTeamData = hasSelectedTargetTeamData( + teamName, + selectedTeamName, + selectedTeamData?.teamName + ); // Load full team data in the background to enable "as before" details (logs/changes/members). useEffect(() => { @@ -65,13 +76,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { teamName, ]); - const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData; - // Team data is still loading when: - // - selectTeam() hasn't updated selectedTeamName yet (team switch pending) - // - selectedTeamName matches but IPC fetch is still in flight - const isThisTeamLoading = - selectedTeamName !== teamName || - (selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData); + const isFullTeamLoaded = hasTargetTeamData; const taskMap = useMemo(() => { const map = new Map(); @@ -119,12 +124,21 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { const kanbanTaskState = isFullTeamLoaded ? selectedTeamData?.kanbanState.tasks[taskId] : undefined; + const loading = shouldKeepGlobalTaskDialogLoading({ + teamName, + taskId, + selectedTeamName, + selectedTeamDataPresent: hasTargetTeamData, + selectedTeamLoading, + selectedTeamError, + hasTaskInMap: taskMap.has(taskId), + }); return ( -

- Pre-flight check to catch errors before launch +

+ Pre-flight check to catch errors before launch +

diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index cf36a360..f8806e2c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -77,7 +77,8 @@ interface SendMessageDialogProps { } // Sticky action mode — survives dialog close/reopen (component remount) -let stickyActionMode: ActionMode = 'do'; +// Default: 'delegate' for teams (overridden to 'do' if solo/no teammates) +let stickyActionMode: ActionMode = 'delegate'; export const SendMessageDialog = ({ open, diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx index d71e2974..40c8bdf2 100644 --- a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; -import { AlertTriangle, Info } from 'lucide-react'; +import { Info } from 'lucide-react'; interface SkipPermissionsCheckboxProps { id: string; @@ -33,13 +33,13 @@ export const SkipPermissionsCheckbox: React.FC = (
- +

Unleash Claude's full power — no interruptions asking for permission. Autonomous mode — all tools execute without confirmation. Be cautious with untrusted code. diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index ace16587..73546360 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -44,7 +44,11 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; -import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest'; +import { + buildTaskChangeRequestOptions, + buildTaskChangeSignature, + deriveTaskSince, +} from '@renderer/utils/taskChangeRequest'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -78,6 +82,8 @@ import { X, } from 'lucide-react'; +const TASK_CHANGES_AUTO_REFRESH_MS = 20_000; + import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments'; import { WorkflowTimeline } from './StatusHistoryTimeline'; @@ -91,9 +97,20 @@ import type { KanbanTaskState, ResolvedTeamMember, TaskAttachmentMeta, + TaskChangeSetV2, TeamTaskWithKanban, } from '@shared/types'; +function resolveTaskChangePresenceFromResult( + data: Pick +): 'has_changes' | 'no_changes' | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} + interface TaskDetailDialogProps { open: boolean; loading?: boolean; @@ -135,6 +152,7 @@ export const TaskDetailDialog = ({ const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges); + const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); const [logsRefreshing, setLogsRefreshing] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); @@ -142,6 +160,9 @@ export const TaskDetailDialog = ({ const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); + const loadedTaskChangeSummaryKeyRef = useRef(null); + const taskChangesLoadInFlightRef = useRef(false); + const currentTaskChangeSummaryKeyRef = useRef(null); // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); @@ -311,6 +332,17 @@ export const TaskDetailDialog = ({ () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), [currentTask] ); + const taskChangeRequestSignature = useMemo( + () => (taskChangeRequestOptions ? buildTaskChangeSignature(taskChangeRequestOptions) : null), + [taskChangeRequestOptions] + ); + const currentTaskChangeSummaryKey = useMemo( + () => + currentTask + ? `${teamName}:${currentTask.id}:${taskChangeRequestSignature ?? 'default'}` + : null, + [currentTask, teamName, taskChangeRequestSignature] + ); const taskChangeSummaryOptions = useMemo( () => currentTask @@ -323,8 +355,12 @@ export const TaskDetailDialog = ({ ); const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification); + useEffect(() => { + currentTaskChangeSummaryKeyRef.current = currentTaskChangeSummaryKey; + }, [currentTaskChangeSummaryKey]); + const loadTaskChangeSummary = useCallback( - async (forceFresh = false): Promise => { + async (forceFresh = false): Promise => { if ( !currentTask || !taskChangeSummaryOptions || @@ -338,68 +374,114 @@ export const TaskDetailDialog = ({ ...taskChangeSummaryOptions, forceFresh, }); - return data.files; + return data; }, [canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant] ); + const syncTaskChangeSummaryResult = useCallback( + (data: TaskChangeSetV2 | null) => { + setTaskChangesFiles(data?.files ?? null); + if (currentTask && taskChangeRequestOptions) { + recordTaskHasChanges( + teamName, + currentTask.id, + taskChangeRequestOptions, + !!data?.files.length + ); + } + const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; + if (currentTask && nextPresence) { + setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence); + } + }, + [ + currentTask, + recordTaskHasChanges, + setSelectedTeamTaskChangePresence, + taskChangeRequestOptions, + teamName, + ] + ); + + const requestTaskChangeSummary = useCallback( + async ({ + forceFresh = false, + showSpinner = false, + preserveFilesOnError = false, + }: { + forceFresh?: boolean; + showSpinner?: boolean; + preserveFilesOnError?: boolean; + } = {}): Promise => { + const requestKey = currentTaskChangeSummaryKeyRef.current; + if (taskChangesLoadInFlightRef.current) return; + if ( + !requestKey || + !currentTask || + variant !== 'team' || + !canShowTaskChanges || + !onViewChanges + ) + return; + + taskChangesLoadInFlightRef.current = true; + if (showSpinner) { + setTaskChangesLoading(true); + } + setTaskChangesError(null); + + try { + const data = await loadTaskChangeSummary(forceFresh); + if (currentTaskChangeSummaryKeyRef.current !== requestKey) { + return; + } + syncTaskChangeSummaryResult(data); + } catch (error) { + if (currentTaskChangeSummaryKeyRef.current !== requestKey) { + return; + } + if (!preserveFilesOnError) { + setTaskChangesFiles(null); + } + setTaskChangesError( + error instanceof Error ? error.message : 'Failed to load task changes summary' + ); + } finally { + taskChangesLoadInFlightRef.current = false; + if (showSpinner) { + setTaskChangesLoading(false); + } + } + }, + [ + canShowTaskChanges, + currentTask, + loadTaskChangeSummary, + onViewChanges, + syncTaskChangeSummaryResult, + variant, + ] + ); + useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) return; - let cancelled = false; + const summaryKey = currentTaskChangeSummaryKey; + if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { + return; + } + loadedTaskChangeSummaryKeyRef.current = summaryKey; + // Show full loading state only when no files are cached yet; // otherwise let the refresh button spinner indicate background reload. - if (!taskChangesFiles || taskChangesFiles.length === 0) { - setTaskChangesLoading(true); - } - setTaskChangesError(null); - void loadTaskChangeSummary() - .then((files) => { - if (!cancelled) { - setTaskChangesFiles(files ?? null); - if (currentTask && taskChangeRequestOptions) { - recordTaskHasChanges( - teamName, - currentTask.id, - taskChangeRequestOptions, - !!files?.length - ); - } - } - }) - .catch((error) => { - if (!cancelled) { - setTaskChangesFiles(null); - setTaskChangesError( - error instanceof Error ? error.message : 'Failed to load task changes summary' - ); - } - }) - .finally(() => { - if (!cancelled) setTaskChangesLoading(false); - }); - - void loadTaskChangeSummary(true) - .then((files) => { - if (!cancelled && files) { - setTaskChangesFiles(files); - if (currentTask && taskChangeRequestOptions) { - recordTaskHasChanges( - teamName, - currentTask.id, - taskChangeRequestOptions, - files.length > 0 - ); - } - } - }) - .catch(() => undefined); - - return () => { - cancelled = true; - }; + void requestTaskChangeSummary({ + forceFresh: false, + showSpinner: !taskChangesFiles || taskChangesFiles.length === 0, + preserveFilesOnError: false, + }); }, [ changesSectionOpen, open, @@ -407,40 +489,54 @@ export const TaskDetailDialog = ({ canShowTaskChanges, teamName, onViewChanges, - taskSince, + currentTaskChangeSummaryKey, + taskChangeRequestSignature, variant, - loadTaskChangeSummary, + requestTaskChangeSummary, + taskChangesFiles, ]); - const handleRefreshChanges = useCallback(() => { - if (!currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges) return; - setTaskChangesLoading(true); - setTaskChangesError(null); - void loadTaskChangeSummary(true) - .then((files) => { - setTaskChangesFiles(files ?? null); - if (currentTask && taskChangeRequestOptions) { - recordTaskHasChanges(teamName, currentTask.id, taskChangeRequestOptions, !!files?.length); - } - }) - .catch((error) => { - setTaskChangesFiles(null); - setTaskChangesError( - error instanceof Error ? error.message : 'Failed to load task changes summary' - ); - }) - .finally(() => setTaskChangesLoading(false)); + useEffect(() => { + if (!open || !changesSectionOpen) { + loadedTaskChangeSummaryKeyRef.current = null; + } + }, [open, changesSectionOpen]); + + useEffect(() => { + if (variant !== 'team') return; + if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) { + return; + } + + const timer = window.setInterval(() => { + void requestTaskChangeSummary({ + forceFresh: true, + showSpinner: false, + preserveFilesOnError: true, + }); + }, TASK_CHANGES_AUTO_REFRESH_MS); + + return () => { + window.clearInterval(timer); + }; }, [ + changesSectionOpen, + open, currentTask, canShowTaskChanges, onViewChanges, - loadTaskChangeSummary, - recordTaskHasChanges, - taskChangeRequestOptions, - teamName, + requestTaskChangeSummary, variant, ]); + const handleRefreshChanges = useCallback(() => { + void requestTaskChangeSummary({ + forceFresh: true, + showSpinner: true, + preserveFilesOnError: false, + }); + }, [requestTaskChangeSummary]); + const handleDependencyClick = (taskId: string): void => { // Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap, // since kanban cards use the full UUID in data-task-id. @@ -531,7 +627,7 @@ export const TaskDetailDialog = ({ open={open} onOpenChange={(v) => { if (!v && lightboxOpenRef.current) return; - if (!v) onClose(); + if (!v) handleClose(); }} > { - const [expanded, setExpanded] = useState(false); +export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: () => void }> = ({ + expanded, + onToggle, +}) => ( + +); + +export const ToolApprovalSettingsContent: React.FC<{ + expanded: boolean; + teamName?: string; +}> = ({ expanded, teamName }) => { const [localSeconds, setLocalSeconds] = useState(''); const settings = useStore((s) => s.toolApprovalSettings); - const updateSettings = useStore((s) => s.updateToolApprovalSettings); + const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings); + const updateSettings = useCallback( + (patch: Partial) => rawUpdateSettings(patch, teamName), + [rawUpdateSettings, teamName] + ); + + if (!expanded) return null; return ( -

- {/* Toggle button */} - + + void updateSettings({ autoAllowFileEdits: checked === true }) + } + /> + Auto-allow file edits (Edit, Write, NotebookEdit) + - {/* Collapsible panel */} - {expanded && ( -
+ + void updateSettings({ autoAllowSafeBash: checked === true }) + } + /> + Auto-allow safe commands (git, pnpm, npm, ls...) + + + {/* Separator */} +
+ + {/* Timeout section */} +
+ On timeout: + + + {settings.timeoutAction !== 'wait' && ( + <> + after + setLocalSeconds(e.target.value)} + onBlur={() => { + const val = parseInt(localSeconds, 10); + if (!isNaN(val) && val >= 5 && val <= 300) { + void updateSettings({ timeoutSeconds: val }); + } + setLocalSeconds(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} + className="w-14 rounded border px-1.5 py-0.5 text-center text-xs" + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} /> - Auto-allow all tools - - - {/* Separator */} -
- - {/* Auto-allow file edits */} - - - {/* Auto-allow safe bash */} - - - {/* Separator */} -
- - {/* Timeout section */} -
- On timeout: - - - {settings.timeoutAction !== 'wait' && ( - <> - after - setLocalSeconds(e.target.value)} - onBlur={() => { - const val = parseInt(localSeconds, 10); - if (!isNaN(val) && val >= 5 && val <= 300) { - void updateSettings({ timeoutSeconds: val }); - } - setLocalSeconds(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - } - }} - className="w-14 rounded border px-1.5 py-0.5 text-center text-xs" - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} - /> - sec - - )} -
-
- )} + sec + + )} +
); }; diff --git a/src/renderer/components/team/dialogs/globalTaskDetailDialogLoading.ts b/src/renderer/components/team/dialogs/globalTaskDetailDialogLoading.ts new file mode 100644 index 00000000..cec07951 --- /dev/null +++ b/src/renderer/components/team/dialogs/globalTaskDetailDialogLoading.ts @@ -0,0 +1,34 @@ +interface GlobalTaskDialogLoadingParams { + teamName: string; + taskId: string; + selectedTeamName: string | null; + selectedTeamDataPresent: boolean; + selectedTeamLoading: boolean; + selectedTeamError: string | null; + hasTaskInMap: boolean; +} + +export function hasSelectedTargetTeamData( + targetTeamName: string, + selectedTeamName: string | null, + selectedDataTeamName: string | null | undefined +): boolean { + return selectedTeamName === targetTeamName && selectedDataTeamName === targetTeamName; +} + +export function shouldKeepGlobalTaskDialogLoading({ + teamName, + taskId, + selectedTeamName, + selectedTeamDataPresent, + selectedTeamLoading, + selectedTeamError, + hasTaskInMap, +}: GlobalTaskDialogLoadingParams): boolean { + if (!teamName || !taskId) return false; + if (selectedTeamName !== teamName) return true; + if (selectedTeamLoading && !selectedTeamDataPresent) return true; + if (selectedTeamDataPresent) return false; + if (selectedTeamError) return false; + return !hasTaskInMap; +} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 68cd000f..6c5e28ef 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -8,6 +8,7 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { CheckCircle2, ClipboardList, @@ -201,7 +202,7 @@ interface SortableKanbanTaskCardProps { kanbanState: KanbanState; compact?: boolean; taskMap: Map; - members: ResolvedTeamMember[]; + memberColorMap: Map; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -222,7 +223,7 @@ const SortableKanbanTaskCard = ({ kanbanState, compact, taskMap, - members, + memberColorMap, onRequestReview, onApprove, onRequestChanges, @@ -257,7 +258,7 @@ const SortableKanbanTaskCard = ({ hasReviewers={kanbanState.reviewers.length > 0} compact={compact} taskMap={taskMap} - members={members} + memberColorMap={memberColorMap} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -306,7 +307,27 @@ export const KanbanBoard = ({ const enableTaskSorting = viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` + ); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } + + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); const grouped = useMemo(() => { const result = new Map( COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) @@ -406,7 +427,7 @@ export const KanbanBoard = ({ kanbanState={kanbanState} compact={compact} taskMap={taskMap} - members={members} + memberColorMap={memberColorMap} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -437,7 +458,7 @@ export const KanbanBoard = ({ hasReviewers={kanbanState.reviewers.length > 0} compact={compact} taskMap={taskMap} - members={members} + memberColorMap={memberColorMap} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index 17291023..12ebdee3 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -5,6 +5,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { displayMemberName } from '@renderer/utils/memberHelpers'; +import { formatSessionLabel } from '@renderer/utils/sessionTitleParser'; import { Crown, Filter } from 'lucide-react'; import type { Session } from '@renderer/types/data'; @@ -122,7 +123,7 @@ export const KanbanFilterPopover = ({ {sessions.map((session) => { const isLead = session.id === leadSessionId; const isSelected = filter.sessionId === session.id; - const label = session.firstMessage?.slice(0, 50) ?? session.id.slice(0, 8); + const label = formatSessionLabel(session.firstMessage) || session.id.slice(0, 8); return ( @@ -215,246 +216,201 @@ const TaskActionIconButton = ({ ); -export const KanbanTaskCard = ({ - task, - teamName, - columnId, - kanbanTaskState, - hasReviewers, - compact, - taskMap, - members, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onDeleteTask, -}: KanbanTaskCardProps): React.JSX.Element => { - const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); - const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; - const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; - const hasBlockedBy = blockedByIds.length > 0; - const hasBlocks = blocksIds.length > 0; - - // Lazy-check if task has file changes - const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); - const canDisplay = useMemo( - () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, - [taskChangeRequestOptions, onViewChanges] - ); - const cacheKey = useMemo( - () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), - [teamName, task.id, taskChangeRequestOptions] - ); - const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]); - const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); - - useEffect(() => { - if (canDisplay && taskHasChanges === undefined) { - void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); - } - }, [ - canDisplay, - task.id, +export const KanbanTaskCard = memo( + function KanbanTaskCard({ + task, teamName, - taskHasChanges, - checkTaskHasChanges, - taskChangeRequestOptions, - ]); + columnId, + kanbanTaskState, + hasReviewers, + compact, + taskMap, + memberColorMap, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onDeleteTask, + }: KanbanTaskCardProps): React.JSX.Element { + const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); + const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; + const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; + const hasBlockedBy = blockedByIds.length > 0; + const hasBlocks = blocksIds.length > 0; - const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; - const metaActions = ( - <> - {canDisplay && taskHasChanges === true ? ( - } - variant="ghost" - className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" - onClick={(e) => { - e.stopPropagation(); - onViewChanges!(task.id); - }} - /> - ) : null} - - {onDeleteTask ? ( - } - variant="ghost" - className="text-red-400 hover:bg-red-500/10 hover:text-red-300" - onClick={(e) => { - e.stopPropagation(); - onDeleteTask(task.id); - }} - /> - ) : null} - - ); + const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); + const canDisplay = useMemo( + () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, + [taskChangeRequestOptions, onViewChanges] + ); - return ( -
onTaskClick?.(task)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onTaskClick?.(task); - } - }} - > - - {formatTaskDisplayLabel(task)} - - {task.owner ? ( - - + const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; + const metaActions = ( + <> + {canDisplay && task.changePresence === 'has_changes' ? ( + } + variant="ghost" + className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" + onClick={(e) => { + e.stopPropagation(); + onViewChanges!(task.id); + }} + /> + ) : canDisplay && task.changePresence === 'no_changes' ? ( + + No changes + + ) : null} + + {onDeleteTask ? ( + } + variant="ghost" + className="text-red-400 hover:bg-red-500/10 hover:text-red-300" + onClick={(e) => { + e.stopPropagation(); + onDeleteTask(task.id); + }} + /> + ) : null} + + ); + + return ( +
onTaskClick?.(task)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTaskClick?.(task); + } + }} + > + + {formatTaskDisplayLabel(task)} - ) : null} -
- {!compact && } - {task.needsClarification ? ( - - - {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + {task.owner ? ( + + ) : null} - {task.reviewState === 'needsFix' ? ( - - {REVIEW_STATE_DISPLAY.needsFix.label} - +
+ {!compact && } + {task.needsClarification ? ( + + + {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + + ) : null} + {task.reviewState === 'needsFix' ? ( + + {REVIEW_STATE_DISPLAY.needsFix.label} + + ) : null} + {compact && } +
+ + {hasBlockedBy ? ( +
+ + + Blocked by + + {blockedByIds.map((id) => ( + + ))} +
) : null} - {compact && } -
- {hasBlockedBy ? ( -
- - - Blocked by - - {blockedByIds.map((id) => ( - - ))} -
- ) : null} - - {hasBlocks ? ( -
- - - Blocks - - {blocksIds.map((id) => ( - - ))} -
- ) : null} - -
-
- {columnId === 'todo' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onStartTask(task.id); - }} + {hasBlocks ? ( +
+ + + Blocks + + {blocksIds.map((id) => ( + - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - ) : null} + ))} +
+ ) : null} - {columnId === 'in_progress' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - - ) : null} +
+
+ {columnId === 'todo' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onStartTask(task.id); + }} + /> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + ) : null} - {columnId === 'done' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onApprove(task.id); - }} - /> - } - className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" - onClick={(e) => { - e.stopPropagation(); - onRequestReview(task.id); - }} - /> - - ) : null} + {columnId === 'in_progress' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + + ) : null} - {columnId === 'review' ? ( -
- {isReviewManual ? ( -
- Manual review -
- ) : null} -
+ {columnId === 'done' ? ( + <> } @@ -465,34 +421,84 @@ export const KanbanTaskCard = ({ }} /> } - variant="destructive" - className="bg-red-500/90 text-white hover:bg-red-500" + label="Request review" + icon={} + className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" onClick={(e) => { e.stopPropagation(); - onRequestChanges(task.id); + onRequestReview(task.id); }} /> + + ) : null} + + {columnId === 'review' ? ( +
+ {isReviewManual ? ( +
+ Manual review +
+ ) : null} +
+ } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onApprove(task.id); + }} + /> + } + variant="destructive" + className="bg-red-500/90 text-white hover:bg-red-500" + onClick={(e) => { + e.stopPropagation(); + onRequestChanges(task.id); + }} + /> +
-
- ) : null} + ) : null} - {columnId === 'approved' ? ( - } - className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" - onClick={(e) => { - e.stopPropagation(); - onMoveBackToDone(task.id); - }} - /> - ) : null} + {columnId === 'approved' ? ( + } + className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" + onClick={(e) => { + e.stopPropagation(); + onMoveBackToDone(task.id); + }} + /> + ) : null} +
+ +
{metaActions}
- -
{metaActions}
-
- ); -}; + ); + }, + (prev, next) => + prev.task === next.task && + prev.teamName === next.teamName && + prev.columnId === next.columnId && + prev.kanbanTaskState === next.kanbanTaskState && + prev.hasReviewers === next.hasReviewers && + prev.compact === next.compact && + prev.taskMap === next.taskMap && + prev.memberColorMap === next.memberColorMap && + prev.onRequestReview === next.onRequestReview && + prev.onApprove === next.onApprove && + prev.onRequestChanges === next.onRequestChanges && + prev.onMoveBackToDone === next.onMoveBackToDone && + prev.onStartTask === next.onStartTask && + prev.onCompleteTask === next.onCompleteTask && + prev.onCancelTask === next.onCancelTask && + prev.onScrollToTask === next.onScrollToTask && + prev.onTaskClick === next.onTaskClick && + prev.onViewChanges === next.onViewChanges && + prev.onDeleteTask === next.onDeleteTask +); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 30902e9d..b21e5985 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -25,6 +25,7 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; @@ -198,8 +199,21 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + const slashCommandSuggestions = useMemo( + () => + KNOWN_SLASH_COMMANDS.map((command) => ({ + id: `command:${command.name}`, + name: command.name, + command: command.command, + description: command.description, + subtitle: command.description, + type: 'command', + })), + [] + ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); + const standaloneSlashCommand = useMemo(() => parseStandaloneSlashCommand(trimmed), [trimmed]); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; @@ -265,6 +279,17 @@ export const MessageComposer = ({ : 'Team must be online to attach files' : undefined; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; + const slashCommandRestrictionReason = standaloneSlashCommand + ? draft.attachments.length > 0 + ? 'Slash commands require a live team lead and cannot be sent with attachments' + : isCrossTeam + ? 'Slash commands can only be run on the current team lead' + : !isLeadRecipient + ? 'Slash commands can only be sent to the team lead' + : !isTeamAlive + ? 'Slash commands require the team lead to be online' + : null + : null; const canSend = recipient.length > 0 && trimmed.length > 0 && @@ -272,6 +297,7 @@ export const MessageComposer = ({ !sending && !isProvisioning && !attachmentsBlocked && + !slashCommandRestrictionReason && (!isCrossTeam || onCrossTeamSend !== undefined); // Track whether we initiated a send — clear draft only on confirmed success @@ -870,6 +896,7 @@ export const MessageComposer = ({ suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} taskSuggestions={taskSuggestions} + commandSuggestions={slashCommandSuggestions} chips={draft.chips} onChipRemove={draft.removeChip} projectPath={projectPath} @@ -877,6 +904,7 @@ export const MessageComposer = ({ onModEnter={handleSend} onShiftTab={handleCycleActionMode} dismissMentionsRef={dismissMentionsRef} + extraTips={['Tip: You can use "/" to run any Claude commands.']} minRows={2} maxRows={6} maxLength={MAX_TEXT_LENGTH} @@ -918,7 +946,9 @@ export const MessageComposer = ({ - {isProvisioning && !sending ? ( + {slashCommandRestrictionReason ? ( + {slashCommandRestrictionReason} + ) : isProvisioning && !sending ? ( Sending unavailable while team is launching @@ -928,7 +958,12 @@ export const MessageComposer = ({ } footerRight={
- {sendError ? ( + {slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( {sendError} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9677a850..b5954ca2 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -24,6 +24,10 @@ import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; +import { + getTeamMessagesSidebarUiState, + setTeamMessagesSidebarUiState, +} from '../sidebar/teamSidebarUiState'; import { MessageComposer } from './MessageComposer'; import { MessagesFilterPopover } from './MessagesFilterPopover'; @@ -110,20 +114,72 @@ export const MessagesPanel = memo(function MessagesPanel({ const openTeamTab = useStore((s) => s.openTeamTab); const composerTextareaRef = useRef(null); + const sidebarScrollRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); - const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); - const [messagesFilter, setMessagesFilter] = useState({ - from: new Set(), - to: new Set(), - showNoise: false, - }); - const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(true); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false); - const [expandedItemKey, setExpandedItemKey] = useState(null); + const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName)); + const [messagesSearchQuery, setMessagesSearchQuery] = useState( + initialSidebarStateRef.current.messagesSearchQuery + ); + const [messagesFilter, setMessagesFilter] = useState( + initialSidebarStateRef.current.messagesFilter + ); + const [messagesFilterOpen, setMessagesFilterOpen] = useState( + initialSidebarStateRef.current.messagesFilterOpen + ); + const [messagesCollapsed, setMessagesCollapsed] = useState( + initialSidebarStateRef.current.messagesCollapsed + ); + const [sidebarSearchVisible, setSidebarSearchVisible] = useState( + initialSidebarStateRef.current.sidebarSearchVisible + ); + const [expandedItemKey, setExpandedItemKey] = useState( + initialSidebarStateRef.current.expandedItemKey + ); + const [sidebarScrollTop, setSidebarScrollTop] = useState( + initialSidebarStateRef.current.sidebarScrollTop + ); + + useEffect(() => { + initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); + setMessagesSearchQuery(initialSidebarStateRef.current.messagesSearchQuery); + setMessagesFilter(initialSidebarStateRef.current.messagesFilter); + setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); + setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); + setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); + setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + }, [teamName]); + + useEffect(() => { + setTeamMessagesSidebarUiState(teamName, { + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + }); + }, [ + teamName, + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + ]); + + useLayoutEffect(() => { + if (position !== 'sidebar') return; + const el = sidebarScrollRef.current; + if (!el) return; + el.scrollTop = sidebarScrollTop; + }, [position, sidebarScrollTop]); const filteredMessages = useMemo(() => { return filterTeamMessages(messages, { @@ -479,7 +535,11 @@ export const MessagesPanel = memo(function MessagesPanel({
)} {/* Scrollable content */} -
+
setSidebarScrollTop(e.currentTarget.scrollTop)} + >
{ const localEditorViewRef = useRef(null); const sentinelRef = useRef(null); + const canRenderSnippetPreview = shouldRenderSnippetReviewPreview(file.snippets); // Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView. // This fires on every editor lifecycle event: initial mount, key-change remount, @@ -87,7 +92,11 @@ export const FileSectionDiff = ({ return (
- + {canRenderSnippetPreview ? ( + + ) : ( + + )}
); @@ -115,18 +124,29 @@ export const FileSectionDiff = ({ // pretend it's empty — fall back to snippet-level diff. const canRenderCodeMirror = resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null); + const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? ''); + const canRenderCodeMirrorSafely = + canRenderCodeMirror && + shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? ''); - if (!canRenderCodeMirror) { + if (!canRenderCodeMirrorSafely) { return (
- + + {canRenderSnippetPreview ? : null}
); } - const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? ''); - return (
{isMissingOnDisk && ( @@ -170,3 +190,11 @@ export const FileSectionDiff = ({
); }; + +const OversizedDiffNotice = ({ message }: { message: string }): React.ReactElement => { + return ( +
+ {message} +
+ ); +}; diff --git a/src/renderer/components/team/review/reviewDiffSafety.ts b/src/renderer/components/team/review/reviewDiffSafety.ts new file mode 100644 index 00000000..1b9f1f14 --- /dev/null +++ b/src/renderer/components/team/review/reviewDiffSafety.ts @@ -0,0 +1,59 @@ +import type { SnippetDiff } from '@shared/types/review'; + +const MAX_CODEMIRROR_DIFF_COMBINED_BYTES = 2 * 1024 * 1024; +const MAX_CODEMIRROR_DIFF_LINE_PRODUCT = 1_000_000; +const MAX_SNIPPET_PREVIEW_COMBINED_BYTES = 512 * 1024; +const MAX_SNIPPET_PREVIEW_TOTAL_LINES = 4_000; + +function countLinesUpTo(text: string, limit: number): number { + let lines = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + lines++; + if (lines > limit) { + return lines; + } + } + } + return lines; +} + +export function shouldRenderCodeMirrorReviewDiff(original: string, modified: string): boolean { + const combinedBytes = original.length + modified.length; + if (combinedBytes > MAX_CODEMIRROR_DIFF_COMBINED_BYTES) { + return false; + } + + const oldLines = countLinesUpTo(original, MAX_CODEMIRROR_DIFF_LINE_PRODUCT + 1); + const newLines = countLinesUpTo(modified, MAX_CODEMIRROR_DIFF_LINE_PRODUCT + 1); + + return oldLines * newLines <= MAX_CODEMIRROR_DIFF_LINE_PRODUCT; +} + +export function shouldRenderSnippetReviewPreview(snippets: SnippetDiff[]): boolean { + let totalBytes = 0; + let totalLines = 0; + + for (const snippet of snippets) { + if (snippet.isError) { + continue; + } + + totalBytes += snippet.oldString.length + snippet.newString.length; + if (totalBytes > MAX_SNIPPET_PREVIEW_COMBINED_BYTES) { + return false; + } + + totalLines += countLinesUpTo(snippet.oldString, MAX_SNIPPET_PREVIEW_TOTAL_LINES); + if (totalLines > MAX_SNIPPET_PREVIEW_TOTAL_LINES) { + return false; + } + + totalLines += countLinesUpTo(snippet.newString, MAX_SNIPPET_PREVIEW_TOTAL_LINES); + if (totalLines > MAX_SNIPPET_PREVIEW_TOTAL_LINES) { + return false; + } + } + + return true; +} diff --git a/src/renderer/components/team/sidebar/TeamSidebarHost.tsx b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx new file mode 100644 index 00000000..b36388bd --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useId, useLayoutEffect, useState } from 'react'; + +import { useStore } from '@renderer/store'; + +import { + removeTeamSidebarHost, + upsertTeamSidebarHost, + useTeamSidebarPortalSnapshot, +} from './TeamSidebarPortalManager'; + +import type { TeamSidebarSurface } from './TeamSidebarPortalManager'; + +const TeamSidebarHostContext = createContext(null); + +interface TeamSidebarHostProps { + teamName: string; + surface: TeamSidebarSurface; + isActive: boolean; + isFocused: boolean; + children?: React.ReactNode; +} + +export function useTeamSidebarHostId(): string | null { + return useContext(TeamSidebarHostContext); +} + +export const TeamSidebarHost = ({ + teamName, + surface, + isActive, + isFocused, + children, +}: TeamSidebarHostProps): React.JSX.Element => { + const hostId = useId(); + const [element, setElement] = useState(null); + const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + })); + const snapshot = useTeamSidebarPortalSnapshot(); + const isVisible = messagesPanelMode === 'sidebar'; + const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; + + useLayoutEffect(() => { + upsertTeamSidebarHost(hostId, { + teamName, + surface, + element, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarHost(hostId); + }; + }, [element, hostId, isActive, isFocused, surface, teamName]); + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts new file mode 100644 index 00000000..83de088a --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts @@ -0,0 +1,173 @@ +import { useSyncExternalStore } from 'react'; + +export type TeamSidebarSurface = 'team' | 'graph-tab' | 'graph-overlay'; + +interface TeamSidebarHostEntry { + id: string; + teamName: string; + surface: TeamSidebarSurface; + element: HTMLElement | null; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSourceEntry { + id: string; + teamName: string; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSnapshot { + version: number; + activeHostIdByTeam: Record; + activeSourceIdByTeam: Record; +} + +const SURFACE_PRIORITY: Record = { + team: 1, + 'graph-tab': 2, + 'graph-overlay': 3, +}; + +const hostById = new Map(); +const sourceById = new Map(); +const listeners = new Set<() => void>(); +let version = 0; +let nextOrder = 1; + +function emit(): void { + version += 1; + for (const listener of listeners) { + listener(); + } +} + +function sortHosts(a: TeamSidebarHostEntry, b: TeamSidebarHostEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + const priorityDiff = SURFACE_PRIORITY[b.surface] - SURFACE_PRIORITY[a.surface]; + if (priorityDiff !== 0) return priorityDiff; + return b.order - a.order; +} + +function sortSources(a: TeamSidebarSourceEntry, b: TeamSidebarSourceEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + return b.order - a.order; +} + +function buildSnapshot(): TeamSidebarSnapshot { + const activeHostIdByTeam: Record = {}; + const activeSourceIdByTeam: Record = {}; + + const hostsByTeam = new Map(); + for (const host of hostById.values()) { + if (!host.element) continue; + const list = hostsByTeam.get(host.teamName) ?? []; + list.push(host); + hostsByTeam.set(host.teamName, list); + } + for (const [teamName, hosts] of hostsByTeam.entries()) { + const winner = [...hosts].sort(sortHosts)[0]; + if (winner) activeHostIdByTeam[teamName] = winner.id; + } + + const sourcesByTeam = new Map(); + for (const source of sourceById.values()) { + const list = sourcesByTeam.get(source.teamName) ?? []; + list.push(source); + sourcesByTeam.set(source.teamName, list); + } + for (const [teamName, sources] of sourcesByTeam.entries()) { + const winner = [...sources].sort(sortSources)[0]; + if (winner) activeSourceIdByTeam[teamName] = winner.id; + } + + return { + version, + activeHostIdByTeam, + activeSourceIdByTeam, + }; +} + +let cachedSnapshot = buildSnapshot(); + +function refreshSnapshot(): void { + cachedSnapshot = buildSnapshot(); + emit(); +} + +export function upsertTeamSidebarHost( + id: string, + entry: Omit +): void { + const existing = hostById.get(id); + hostById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarHost(id: string): void { + if (!hostById.delete(id)) return; + refreshSnapshot(); +} + +export function upsertTeamSidebarSource( + id: string, + entry: Omit +): void { + const existing = sourceById.get(id); + sourceById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarSource(id: string): void { + if (!sourceById.delete(id)) return; + refreshSnapshot(); +} + +export function getTeamSidebarHostElement(hostId: string): HTMLElement | null { + return hostById.get(hostId)?.element ?? null; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function useTeamSidebarPortalSnapshot(): TeamSidebarSnapshot { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function getTeamSidebarPortalSnapshotForTests(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function resetTeamSidebarPortalManagerForTests(): void { + hostById.clear(); + sourceById.clear(); + listeners.clear(); + version = 0; + nextOrder = 1; + cachedSnapshot = buildSnapshot(); +} diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx new file mode 100644 index 00000000..a24da0e1 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx @@ -0,0 +1,66 @@ +import { useId, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import { useStore } from '@renderer/store'; + +import { useTeamSidebarHostId } from './TeamSidebarHost'; +import { + getTeamSidebarHostElement, + removeTeamSidebarSource, + upsertTeamSidebarSource, + useTeamSidebarPortalSnapshot, +} from './TeamSidebarPortalManager'; + +interface TeamSidebarPortalSourceProps { + teamName: string; + isActive: boolean; + isFocused: boolean; + children: React.ReactNode; +} + +export const TeamSidebarPortalSource = ({ + teamName, + isActive, + isFocused, + children, +}: TeamSidebarPortalSourceProps): React.JSX.Element | null => { + const sourceId = useId(); + const hostId = useTeamSidebarHostId(); + const messagesPanelMode = useStore((s) => s.messagesPanelMode); + const snapshot = useTeamSidebarPortalSnapshot(); + + useLayoutEffect(() => { + upsertTeamSidebarSource(sourceId, { + teamName, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarSource(sourceId); + }; + }, [isActive, isFocused, sourceId, teamName]); + + if (!hostId || messagesPanelMode !== 'sidebar') { + return null; + } + + if (snapshot.activeSourceIdByTeam[teamName] !== sourceId) { + return null; + } + + const activeHostId = snapshot.activeHostIdByTeam[teamName]; + if (!activeHostId) { + return null; + } + + if (activeHostId === hostId) { + return <>{children}; + } + + const target = getTeamSidebarHostElement(activeHostId); + if (!target) { + return null; + } + + return createPortal(children, target); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx new file mode 100644 index 00000000..73331d28 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -0,0 +1,37 @@ +import { ClaudeLogsSection } from '../ClaudeLogsSection'; +import { MessagesPanel } from '../messages/MessagesPanel'; + +import type { MouseEventHandler } from 'react'; +import type { ComponentProps } from 'react'; + +type SharedMessagesPanelProps = Omit, 'position'>; + +interface TeamSidebarRailProps { + teamName: string; + messagesPanelProps: SharedMessagesPanelProps; + isResizing: boolean; + onResizeMouseDown: MouseEventHandler; +} + +export const TeamSidebarRail = ({ + teamName, + messagesPanelProps, + isResizing, + onResizeMouseDown, +}: TeamSidebarRailProps): React.JSX.Element => { + return ( +
+
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/teamSidebarUiState.ts b/src/renderer/components/team/sidebar/teamSidebarUiState.ts new file mode 100644 index 00000000..ae7acd82 --- /dev/null +++ b/src/renderer/components/team/sidebar/teamSidebarUiState.ts @@ -0,0 +1,122 @@ +import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover'; + +import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover'; +import type { ClaudeLogsViewerState } from '../CliLogsRichView'; +import type { MessagesFilterState } from '../messages/MessagesFilterPopover'; + +export interface TeamMessagesSidebarUiState { + messagesSearchQuery: string; + messagesFilter: MessagesFilterState; + messagesFilterOpen: boolean; + messagesCollapsed: boolean; + sidebarSearchVisible: boolean; + expandedItemKey: string | null; + sidebarScrollTop: number; +} + +export interface TeamClaudeLogsSidebarUiState { + searchQuery: string; + filter: ClaudeLogsFilterState; + filterOpen: boolean; + viewerState: ClaudeLogsViewerState; +} + +const messagesStateByTeam = new Map(); +const claudeLogsStateByTeam = new Map(); + +function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState { + return { + from: new Set(filter.from), + to: new Set(filter.to), + showNoise: filter.showNoise, + }; +} + +function cloneClaudeLogsFilter(filter: ClaudeLogsFilterState): ClaudeLogsFilterState { + return { + streams: new Set(filter.streams), + kinds: new Set(filter.kinds), + }; +} + +function cloneViewerState(viewerState: ClaudeLogsViewerState): ClaudeLogsViewerState { + return { + collapsedGroupIds: new Set(viewerState.collapsedGroupIds), + expandedItemIds: new Set(viewerState.expandedItemIds), + expandedSubagentIds: new Set(viewerState.expandedSubagentIds), + viewport: { ...viewerState.viewport }, + }; +} + +export function createDefaultMessagesSidebarUiState(): TeamMessagesSidebarUiState { + return { + messagesSearchQuery: '', + messagesFilter: { + from: new Set(), + to: new Set(), + showNoise: false, + }, + messagesFilterOpen: false, + messagesCollapsed: true, + sidebarSearchVisible: false, + expandedItemKey: null, + sidebarScrollTop: 0, + }; +} + +export function createDefaultClaudeLogsSidebarUiState(): TeamClaudeLogsSidebarUiState { + return { + searchQuery: '', + filter: { + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }, + filterOpen: false, + viewerState: { + collapsedGroupIds: new Set(), + expandedItemIds: new Set(), + expandedSubagentIds: new Set(), + viewport: { mode: 'edge', edge: 'newest' }, + }, + }; +} + +export function getTeamMessagesSidebarUiState(teamName: string): TeamMessagesSidebarUiState { + const state = messagesStateByTeam.get(teamName) ?? createDefaultMessagesSidebarUiState(); + return { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }; +} + +export function setTeamMessagesSidebarUiState( + teamName: string, + state: TeamMessagesSidebarUiState +): void { + messagesStateByTeam.set(teamName, { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }); +} + +export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState { + const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState(); + return { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }; +} + +export function setTeamClaudeLogsSidebarUiState( + teamName: string, + state: TeamClaudeLogsSidebarUiState +): void { + claudeLogsStateByTeam.set(teamName, { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }); +} diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 6f190a88..430ad446 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -13,6 +13,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { + createDefaultClaudeLogsSidebarUiState, + getTeamClaudeLogsSidebarUiState, + setTeamClaudeLogsSidebarUiState, +} from './sidebar/teamSidebarUiState'; import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; @@ -364,12 +369,7 @@ function filterStreamJsonText( // ============================================================================= function createDefaultViewerState(): ClaudeLogsViewerState { - return { - collapsedGroupIds: new Set(), - expandedItemIds: new Set(), - expandedSubagentIds: new Set(), - viewport: { mode: 'edge', edge: 'newest' }, - }; + return createDefaultClaudeLogsSidebarUiState().viewerState; } // ============================================================================= @@ -389,15 +389,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController const [error, setError] = useState(null); // ── Search & filter state ───────────────────────────────────────────── - const [searchQuery, setSearchQuery] = useState(''); - const [filter, setFilter] = useState(() => ({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - })); - const [filterOpen, setFilterOpen] = useState(false); + const initialSidebarStateRef = useRef(getTeamClaudeLogsSidebarUiState(teamName)); + const [searchQuery, setSearchQuery] = useState(initialSidebarStateRef.current.searchQuery); + const [filter, setFilter] = useState( + initialSidebarStateRef.current.filter + ); + const [filterOpen, setFilterOpen] = useState(initialSidebarStateRef.current.filterOpen); // ── Viewer state (expansion + viewport) ─────────────────────────────── - const [viewerState, setViewerState] = useState(createDefaultViewerState); + const [viewerState, setViewerState] = useState( + initialSidebarStateRef.current.viewerState + ); const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => { setViewerState(state); @@ -415,6 +417,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController // ── Reset on team change ────────────────────────────────────────────── useEffect(() => { + initialSidebarStateRef.current = getTeamClaudeLogsSidebarUiState(teamName); setLoadedCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); @@ -422,14 +425,21 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController latestRef.current = null; atTopRef.current = true; setError(null); - setSearchQuery(''); - setFilter({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - }); - setViewerState(createDefaultViewerState()); + setSearchQuery(initialSidebarStateRef.current.searchQuery); + setFilter(initialSidebarStateRef.current.filter); + setFilterOpen(initialSidebarStateRef.current.filterOpen); + setViewerState(initialSidebarStateRef.current.viewerState); }, [teamName]); + useEffect(() => { + setTeamClaudeLogsSidebarUiState(teamName, { + searchQuery, + filter, + filterOpen, + viewerState, + }); + }, [teamName, searchQuery, filter, filterOpen, viewerState]); + // ── Sync refs ───────────────────────────────────────────────────────── useEffect(() => { committedRef.current = data; diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index cd9d3fca..141c9460 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -5,7 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { Folder, Hash, Loader2, UsersRound } from 'lucide-react'; +import { Command, Folder, Hash, Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -83,10 +83,11 @@ export const MentionSuggestionList = ({ } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'task' | 'file'; + type Section = 'member' | 'team' | 'task' | 'file' | 'command'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'task') return 'task'; + if (s.type === 'command') return 'command'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -96,6 +97,7 @@ export const MentionSuggestionList = ({ team: 'Teams', task: 'Tasks', file: 'Files', + command: 'Commands', }; // Determine which sections are present @@ -114,6 +116,7 @@ export const MentionSuggestionList = ({ const isFileOrFolder = isFile || isFolder; const isTeam = section === 'team'; const isTask = section === 'task'; + const isCommand = section === 'command'; const taskTeamColorSet = isTask && s.color ? getTeamColorSet(s.color) @@ -160,6 +163,8 @@ export const MentionSuggestionList = ({ ) : isTask ? ( + ) : isCommand ? ( + ) : isTeam ? ( - + {!isTask && !isFileOrFolder && s.subtitle ? ( {s.subtitle} @@ -208,6 +218,11 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} + {isCommand && s.description ? ( +
+ {s.description} +
+ ) : null}
{isTeam && s.isOnline !== undefined ? ( void; /** Called when Shift+Tab is pressed. */ onShiftTab?: () => void; /** Ref that receives the dismiss callback to close mention dropdown from outside */ dismissMentionsRef?: React.MutableRefObject<(() => void) | null>; + /** Additional rotating tips to append after the defaults */ + extraTips?: string[]; } export const MentionableTextarea = React.forwardRef( @@ -347,9 +375,11 @@ export const MentionableTextarea = React.forwardRef 0 ? ['@', '#', '/'] : enableTaskSearch ? ['@', '#'] : ['@'], isTriggerEnabled: (triggerChar) => { if (triggerChar === '#') return enableTaskSearch; + if (triggerChar === '/') return commandSuggestions.length > 0; return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0; }, + isTriggerMatchValid: (trigger, text) => { + if (trigger.triggerChar !== '/') return true; + return text.slice(0, trigger.triggerIndex).trim().length === 0; + }, }); // Expose dismiss to parent via ref for external close (e.g. Send button click) @@ -413,7 +449,7 @@ export const MentionableTextarea = React.forwardRef { if (!isOpen || !isAtTrigger) return []; @@ -434,6 +470,12 @@ export const MentionableTextarea = React.forwardRef doesSuggestionMatchQuery(task, query)); }, [taskSuggestions, activeTriggerChar, isOpen, query]); + const filteredCommandSuggestions = React.useMemo(() => { + if (commandSuggestions.length === 0 || !isOpen || activeTriggerChar !== '/') return []; + if (!query) return commandSuggestions; + return commandSuggestions.filter((command) => doesSuggestionMatchQuery(command, query)); + }, [commandSuggestions, activeTriggerChar, isOpen, query]); + // Merged suggestion list: members → online teams → offline teams → files const atSuggestions = React.useMemo(() => { const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline); @@ -444,7 +486,11 @@ export const MentionableTextarea = React.forwardRef { if (!isOpen) return; @@ -607,6 +653,7 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || taskSuggestions.length > 0 || @@ -617,6 +664,11 @@ export const MentionableTextarea = React.forwardRef (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), [suggestions, teamSuggestions] ); + const slashCommand = React.useMemo(() => parseStandaloneSlashCommand(value), [value]); + const knownSlashCommand = React.useMemo( + () => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null), + [slashCommand] + ); const segments = React.useMemo( () => @@ -962,8 +1014,9 @@ export const MentionableTextarea = React.forwardRef 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch); + (suggestions.length > 0 || + enableFiles || + teamSuggestions.length > 0 || + enableTaskSearch || + commandSuggestions.length > 0); const showFooter = showHintRow || footerRight; return ( @@ -1012,6 +1069,26 @@ export const MentionableTextarea = React.forwardRef; } + if (seg.type === 'slash_command') { + return ( + + {seg.value} + + ); + } if (seg.type === 'task') { return ( @@ -1110,6 +1187,16 @@ export const MentionableTextarea = React.forwardRef ) : null} + {slashCommand ? ( + + ) : null} + ; + scrollTop: number; +} + +export const SlashCommandInteractionLayer = ({ + command, + definition, + value, + textareaRef, + scrollTop, +}: SlashCommandInteractionLayerProps): React.JSX.Element | null => { + const [position, setPosition] = React.useState<{ + top: number; + left: number; + width: number; + height: number; + } | null>(null); + + React.useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const [match] = calculateInlineMatchPositions(textarea, value, [ + { + item: command, + start: command.startIndex, + end: command.endIndex, + token: command.raw, + }, + ]); + + if (!match) { + setPosition(null); + return; + } + + setPosition({ + top: match.top, + left: match.left, + width: match.width, + height: match.height, + }); + }, [command, textareaRef, value]); + + if (!definition || !position) return null; + + return ( +
+
+ + +
+ + +
+
{definition.command}
+
+ {definition.description} +
+
+
+ +
+
+ ); +}; diff --git a/src/renderer/features/CLAUDE.md b/src/renderer/features/CLAUDE.md new file mode 100644 index 00000000..50d59279 --- /dev/null +++ b/src/renderer/features/CLAUDE.md @@ -0,0 +1,494 @@ +# Features Directory — Architecture Guide + +All new renderer features live here. Each feature is a self-contained module following **Clean Architecture**, **SOLID**, and **class-based** patterns. + +--- + +## Quick Start + +```bash +mkdir -p src/renderer/features//{ports,adapters,domain,ui,hooks,__tests__} +``` + +--- + +## Directory Structure + +### Full Feature + +``` +src/renderer/features// + ├── ports/ # Interfaces (contracts) — NO implementations + │ ├── DataPort.ts # What data the feature needs (input) + │ ├── EventPort.ts # Callbacks the feature fires (output) + │ ├── ConfigPort.ts# Configuration / theme overrides + │ └── types.ts # Domain value types for this feature + │ + ├── adapters/ # Bridge between project infrastructure and feature + │ └── Adapter.ts # Zustand store → DataPort (ONLY place that imports store) + │ + ├── domain/ # Business logic — pure TS, no React, no UI + │ ├── models/ # Domain entities and value objects (classes) + │ └── services/ # Domain services and use cases (classes) + │ + ├── ui/ # React components — presentation only + │ ├── View.tsx # Main component (orchestrator, entry point) + │ ├── Overlay.tsx # Full-screen overlay variant (if applicable) + │ └── Tab.tsx # Tab wrapper variant (if applicable) + │ + ├── hooks/ # React hooks — thin bridges to domain classes + │ └── use.ts # Instantiates domain services, subscribes to store + │ + ├── __tests__/ # Tests colocated with feature + │ ├── adapters.test.ts # Adapter mapping correctness + │ ├── domain.test.ts # Domain logic unit tests + │ └── ports.test.ts # Port type validation + │ + └── index.ts # Public API barrel — exports ONLY from ui/ and ports/ +``` + +### Minimal Feature (no domain layer) + +Small features that don't need business logic: + +``` +src/renderer/features// + ├── Adapter.ts # Zustand → feature data + ├── View.tsx # Main component + └── index.ts # Public API +``` + +### When to Extract a Workspace Package + +Some features benefit from a separate `packages//` workspace package: + +| Keep in `features/` | Extract to `packages/` | +|---------------------|----------------------| +| Tightly coupled to our UI | Reusable in other projects | +| Uses our Zustand store | Framework-agnostic (only React peer dep) | +| Small (<500 LOC) | Large (>1000 LOC of core logic) | +| No external deps | Has its own dependencies (d3-force, etc.) | + +Example: `agent-graph` has BOTH: +- `packages/agent-graph/` — Canvas rendering, d3-force simulation (reusable, no project coupling) +- `features/agent-graph/` — Adapter + overlay + tab (thin integration, imports from store) + +--- + +## Real-World Example: agent-graph + +``` +features/agent-graph/ ← Integration layer (3 files) + ├── useTeamGraphAdapter.ts ← Adapter: TeamData → GraphDataPort + ├── TeamGraphOverlay.tsx ← UI: full-screen overlay + └── TeamGraphTab.tsx ← UI: tab wrapper + +packages/agent-graph/ ← Isolated package (34 files) + ├── src/ports/ ← GraphDataPort, GraphEventPort, types + ├── src/canvas/ ← Canvas 2D renderers + ├── src/strategies/ ← Strategy pattern per node kind + ├── src/hooks/ ← Simulation, camera, interaction + └── src/components/ ← GraphView, GraphCanvas, Controls +``` + +The adapter (`useTeamGraphAdapter.ts`) is the **only file** that imports from `@renderer/store`. Everything else depends only on port interfaces. + +--- + +## SOLID Principles + +### S — Single Responsibility + +Each layer has exactly one reason to change: + +| Layer | Changes when... | Does NOT change when... | +|-------|----------------|------------------------| +| `ports/` | Feature contract changes | Store structure changes | +| `adapters/` | Store data model changes | Canvas rendering changes | +| `domain/` | Business rules change | React version updates | +| `ui/` | UX/layout changes | Data mapping changes | + +### O — Open-Closed + +Extend via new classes, never modify existing ones: + +```typescript +// ✅ New node kind = new class, zero changes to existing code +class ReviewNodeRenderer implements NodeRenderer { ... } + +// Register it — the registry and canvas loop don't change +NodeRendererRegistry.register(new ReviewNodeRenderer()); +``` + +### L — Liskov Substitution + +Any implementation of a port can replace another without breaking the feature: + +```typescript +// Both adapters satisfy GraphDataPort — feature works with either +class LiveTeamAdapter implements GraphDataPort { ... } // Real-time Zustand data +class MockTeamAdapter implements GraphDataPort { ... } // Static test data +class ReplayTeamAdapter implements GraphDataPort { ... } // Recorded session playback + +// Feature doesn't know or care which one it gets +const view = ; +``` + +### I — Interface Segregation + +Split ports by consumer. Each consumer depends only on what it needs: + +```typescript +// ✅ Three small ports +interface GraphDataPort { nodes: GraphNode[]; edges: GraphEdge[]; } +interface GraphEventPort { onNodeClick?(ref: DomainRef): void; } +interface GraphConfigPort { bloomIntensity?: number; showTasks?: boolean; } + +// ❌ One massive interface — forces every consumer to know about everything +interface GraphPort { + nodes: GraphNode[]; edges: GraphEdge[]; + onNodeClick?(ref: DomainRef): void; + bloomIntensity?: number; showTasks?: boolean; +} +``` + +### D — Dependency Inversion + +High-level modules (feature UI) depend on abstractions (ports), not on low-level modules (Zustand store). + +``` +UI → depends on → Port interface ← implemented by ← Adapter → depends on → Store + +Feature code never touches the store. The adapter translates in both directions. +``` + +--- + +## Class-Based Patterns + +Prefer **classes** over functions for domain logic, services, adapters, and stateful code. Use the **latest ECMAScript class features** (ES2024+). + +### Modern Class Syntax + +```typescript +class TeamGraphAdapter implements GraphDataPort { + // ─── ES private fields (NOT TypeScript `private`) ───────────── + readonly #store: StoreApi; + #cachedNodes: GraphNode[] = []; + #lastTeamName = ''; + + // ─── Static factory (prefer for complex initialization) ─────── + static create(store: StoreApi): TeamGraphAdapter { + return new TeamGraphAdapter(store); + } + + // ─── Constructor with DI ────────────────────────────────────── + constructor(store: StoreApi) { + this.#store = store; + } + + // ─── Accessors (get/set) ────────────────────────────────────── + get nodes(): readonly GraphNode[] { + return this.#cachedNodes; + } + + // ─── Public method (port contract) ──────────────────────────── + adapt(teamData: TeamData): GraphDataPort { + if (teamData.teamName === this.#lastTeamName) return this; + this.#lastTeamName = teamData.teamName; + this.#cachedNodes = this.#buildNodes(teamData); + return this; + } + + // ─── ES private method ──────────────────────────────────────── + #buildNodes(data: TeamData): GraphNode[] { + return data.members.map(m => ({ id: m.name, kind: 'member', ... })); + } + + // ─── Disposable (cleanup) ───────────────────────────────────── + [Symbol.dispose](): void { + this.#cachedNodes = []; + } +} +``` + +### Key Rules + +| Rule | Do | Don't | +|------|-----|-------| +| Private fields | `#field` (ES private) | `private field` (TS keyword) | +| Private methods | `#method()` | `private method()` | +| Readonly fields | `readonly #field` | Mutable when immutability intended | +| Static factory | `static create()` | Complex constructor logic | +| Disposal | `[Symbol.dispose]()` or `dispose()` | Forgetting cleanup | +| Type narrowing | `instanceof` checks | `as` casts | + +### When to Use Classes vs Functions + +| Use Case | Pattern | Why | +|----------|---------|-----| +| Domain models with state | **Class** | Encapsulation, lifecycle | +| Adapters (data mapping) | **Class** with caching | State for memoization | +| Services (business logic) | **Class** with DI | Testable, injectable | +| Canvas renderers | **Class** implementing strategy | Polymorphism | +| React components | **Function component** | React requires it | +| React hooks | **Function** | React requires it | +| Pure stateless utilities | **Function** | Simpler, no overhead | +| Constants | `as const` object | Immutable | + +### Dependency Injection + +Always inject dependencies through the constructor: + +```typescript +class FeatureService { + readonly #data: FeatureDataPort; + readonly #events: FeatureEventPort; + + constructor(data: FeatureDataPort, events: FeatureEventPort) { + this.#data = data; + this.#events = events; + } + + execute(): void { + const result = this.#data.getNodes(); + this.#events.onResult?.(result); + } +} + +// Wiring in a hook: +function useFeature(): FeatureService { + const adapter = useMemo(() => FeatureAdapter.create(store), [store]); + return useMemo(() => new FeatureService(adapter, eventHandler), [adapter]); +} +``` + +### Strategy Pattern + +```typescript +interface NodeRenderer { + readonly kind: string; + draw(ctx: CanvasRenderingContext2D, node: Node): void; + hitTest(node: Node, x: number, y: number): boolean; +} + +class MemberNodeRenderer implements NodeRenderer { + readonly kind = 'member'; + draw(ctx: CanvasRenderingContext2D, node: Node): void { /* ... */ } + hitTest(node: Node, x: number, y: number): boolean { /* ... */ } +} + +class NodeRendererRegistry { + readonly #renderers = new Map(); + + register(renderer: NodeRenderer): this { + this.#renderers.set(renderer.kind, renderer); + return this; + } + + get(kind: string): NodeRenderer | undefined { + return this.#renderers.get(kind); + } +} + +// Usage: +const registry = new NodeRendererRegistry() + .register(new MemberNodeRenderer()) + .register(new TaskNodeRenderer()); +``` + +--- + +## Error Handling + +```typescript +// Domain errors — typed, not string messages +class FeatureError extends Error { + constructor( + readonly code: 'INVALID_DATA' | 'RENDER_FAILED' | 'ADAPTER_ERROR', + message: string, + readonly cause?: unknown, + ) { + super(message); + this.name = 'FeatureError'; + } +} + +// In adapters — catch and wrap external errors +class FeatureAdapter { + adapt(data: unknown): FeatureDataPort { + try { + return this.#transform(data); + } catch (err) { + throw new FeatureError('ADAPTER_ERROR', 'Failed to adapt data', err); + } + } +} + +// In UI — catch at boundary, show fallback +function FeatureView({ data }: Props) { + // React error boundary or try/catch in event handlers + // Never let feature errors crash the host app +} +``` + +--- + +## Inter-Feature Communication + +Features MUST NOT import from each other directly. If two features need to share data: + +``` +Feature A → emits event → Host app (TeamDetailView) → passes data → Feature B +``` + +Pattern: use `CustomEvent` on `window` (same as keyboard shortcuts): + +```typescript +// Feature A fires: +window.dispatchEvent(new CustomEvent('feature-a:data-ready', { detail: { ... } })); + +// Host app listens and passes to Feature B via props/ports +``` + +--- + +## Testing + +Tests live in `__tests__/` inside the feature directory. + +```typescript +// __tests__/adapters.test.ts — test data mapping +describe('FeatureAdapter', () => { + it('maps TeamData members to GraphNodes', () => { + const adapter = new FeatureAdapter(); + const result = adapter.adapt(mockTeamData); + expect(result.nodes).toHaveLength(3); + expect(result.nodes[0].kind).toBe('lead'); + }); +}); + +// __tests__/domain.test.ts — test business logic +describe('SimulationService', () => { + it('applies orbit force to task nodes', () => { + const service = new SimulationService(mockConfig); + service.tick(0.016); + expect(service.nodes[0].x).toBeDefined(); + }); +}); +``` + +Run: `pnpm test -- --testPathPattern=features/` + +--- + +## Integration with Main App + +Features connect through minimal **registration points** in shared files: + +### Tab Registration (3 files) + +```typescript +// 1. src/renderer/types/tabs.ts — add to union +type: '...' | ''; + +// 2. src/renderer/components/layout/PaneContent.tsx — add route +{tab.type === '' && ( + + + +)} + +// 3. src/renderer/components/layout/SortableTab.tsx — add icon +: SomeIcon, +``` + +### Overlay Registration (1 file) + +```typescript +// In host component (e.g., TeamDetailView.tsx): +const FeatureOverlay = lazy(() => + import('@renderer/features//ui/FeatureOverlay') + .then(m => ({ default: m.FeatureOverlay })) +); +``` + +### Keyboard Shortcut (1 file) + +```typescript +// In useKeyboardShortcuts.ts: +if (key === '' && event.shiftKey && !event.altKey) { + window.dispatchEvent(new CustomEvent('toggle-', { detail })); +} +``` + +--- + +## Naming Conventions + +| Entity | Convention | Example | +|--------|-----------|---------| +| Feature directory | `kebab-case` | `agent-graph/` | +| Port interfaces | `PascalCase` + `Port` suffix | `GraphDataPort` | +| Domain classes | `PascalCase` | `SimulationService` | +| Adapter classes | `PascalCase` + `Adapter` suffix | `TeamGraphAdapter` | +| UI components | `PascalCase` | `GraphView`, `GraphOverlay` | +| Hooks | `camelCase` + `use` prefix | `useTeamGraphAdapter` | +| Test files | `.test.ts` | `adapters.test.ts` | +| Type files | `camelCase` or `types.ts` | `types.ts` | +| Barrel | `index.ts` | `index.ts` | + +--- + +## Existing Features + +| Feature | Path | Companion Package | Description | +|---------|------|-------------------|-------------| +| `agent-graph` | `features/agent-graph/` | `packages/agent-graph/` | Force-directed graph visualization | + +--- + +## Anti-Patterns + +```typescript +// ❌ Feature imports from another feature +import { X } from '@renderer/features/other-feature/X'; + +// ❌ UI component imports store directly (only adapters may) +import { useStore } from '@renderer/store'; + +// ❌ Feature imports from @renderer/components/* +import { KanbanBoard } from '@renderer/components/team/kanban/KanbanBoard'; + +// ❌ TypeScript `private` instead of ES #private +class Bad { private field = 1; } // Use: #field = 1; + +// ❌ Mutable global state +let globalCache = {}; + +// ❌ `any` or `as any` +const data = response as any; + +// ❌ God-class with mixed responsibilities +class FeatureManager { + fetchData() { ... } + renderUI() { ... } + handleClick() { ... } + saveToStorage() { ... } +} +``` + +--- + +## Checklist for New Feature PR + +- [ ] Feature lives in `src/renderer/features//` +- [ ] Port interfaces defined (`DataPort`, `EventPort` at minimum) +- [ ] Adapter is the ONLY file importing from `@renderer/store` +- [ ] No cross-feature imports +- [ ] Classes use ES `#private` fields, not TypeScript `private` +- [ ] `index.ts` exports only public API (ui components + port types) +- [ ] Integration points documented (which shared files were modified) +- [ ] Tests in `__tests__/` for adapter and domain logic +- [ ] Typecheck passes: `pnpm typecheck` +- [ ] Build passes: `pnpm build` diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts new file mode 100644 index 00000000..8dfb721c --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -0,0 +1,907 @@ +/** + * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * + * This is the ONLY file in this feature that imports from @renderer/store. + * If the project data model changes, ONLY this class needs updating. + * + * Class-based with ES #private fields, caching, and DI-ready constructor. + */ + +import { getUnreadCount } from '@renderer/services/commentReadStorage'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; +import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + GraphDataPort, + GraphEdge, + GraphNode, + GraphNodeState, + GraphParticle, +} from '@claude-teams/agent-graph'; +import type { + ActiveToolCall, + InboxMessage, + MemberSpawnStatusEntry, + TeamData, +} from '@shared/types/team'; +import type { LeadContextUsage } from '@shared/types/team'; + +export class TeamGraphAdapter { + // ─── ES #private fields ────────────────────────────────────────────────── + #lastTeamName = ''; + #lastDataHash = ''; + #cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult(''); + readonly #seenRelated = new Set(); + readonly #seenMessageIds = new Set(); + #initialMessagesSeen = false; + readonly #seenCommentCounts = new Map(); + #initialCommentsSeen = false; + + // ─── Static factory ────────────────────────────────────────────────────── + static create(): TeamGraphAdapter { + return new TeamGraphAdapter(); + } + + static #emptyResult(teamName: string): GraphDataPort { + return { nodes: [], edges: [], particles: [], teamName, isAlive: false }; + } + + // ─── Public API ────────────────────────────────────────────────────────── + + /** + * Adapt team data into a GraphDataPort snapshot. + * Returns cached result if inputs haven't changed (referential check). + */ + adapt( + teamData: TeamData | null, + teamName: string, + spawnStatuses?: Record, + leadContext?: LeadContextUsage, + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record, + commentReadState?: Record + ): GraphDataPort { + if (teamData?.teamName !== teamName) { + return TeamGraphAdapter.#emptyResult(teamName); + } + + // Simple hash for change detection (avoids full deep equality) + const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); + const memberKey = teamData.members + .map( + (member) => + `${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.removedAt ?? ''}` + ) + .sort() + .join('|'); + const taskKey = teamData.tasks + .map( + (task) => + `${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}` + ) + .sort() + .join('|'); + const processKey = teamData.processes + .map( + (proc) => + `${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}` + ) + .sort() + .join('|'); + const messageKey = teamData.messages + .slice(0, 25) + .map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg)) + .join('|'); + const commentKey = teamData.tasks + .map((task) => { + const comments = task.comments ?? []; + const tail = comments + .slice(Math.max(0, comments.length - 5)) + .map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`) + .join(','); + return `${task.id}:${comments.length}:${tail}`; + }) + .sort() + .join('|'); + const approvalKey = pendingApprovalAgents?.size + ? Array.from(pendingApprovalAgents).sort().join(',') + : ''; + const activeToolKey = activeTools + ? Object.entries(activeTools) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const finishedVisibleKey = finishedVisible + ? Object.entries(finishedVisible) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const historyKey = toolHistory + ? Object.entries(toolHistory) + .map( + ([memberName, tools]) => + `${memberName}:${tools + .slice(0, 3) + .map( + (tool) => + `${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + .join(',')}` + ) + .sort() + .join('|') + : ''; + const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`; + if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { + return this.#cachedResult; + } + + // Reset particle tracking when team changes + if (teamName !== this.#lastTeamName) { + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; + } + + this.#lastTeamName = teamName; + this.#lastDataHash = hash; + this.#seenRelated.clear(); + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const particles: GraphParticle[] = []; + + const leadId = `lead:${teamName}`; + const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); + + this.#buildLeadNode( + nodes, + leadId, + teamData, + teamName, + leadName, + leadContext, + activeTools, + finishedVisible, + toolHistory + ); + this.#buildMemberNodes( + nodes, + edges, + leadId, + teamData, + teamName, + spawnStatuses, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory + ); + this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState); + this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#buildMessageParticles( + particles, + nodes, + teamData.messages, + teamName, + leadId, + leadName, + edges + ); + this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); + + this.#cachedResult = { + nodes, + edges, + particles, + teamName, + teamColor: teamData.config.color ?? undefined, + isAlive: teamData.isAlive, + }; + + return this.#cachedResult; + } + + // ─── Disposal ──────────────────────────────────────────────────────────── + + [Symbol.dispose](): void { + this.#cachedResult = TeamGraphAdapter.#emptyResult(''); + this.#seenRelated.clear(); + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; + this.#lastDataHash = ''; + } + + // ─── Private: node builders ────────────────────────────────────────────── + + static #getLeadMemberName(data: TeamData, teamName: string): string { + return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; + } + + static #selectVisibleTool( + runningTools?: Record, + finishedTools?: Record + ): ActiveToolCall | undefined { + const newestRunning = Object.values(runningTools ?? {}).sort((a, b) => + b.startedAt.localeCompare(a.startedAt) + )[0]; + if (newestRunning) return newestRunning; + return Object.values(finishedTools ?? {}).sort((a, b) => + (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '') + )[0]; + } + + #buildLeadNode( + nodes: GraphNode[], + leadId: string, + data: TeamData, + teamName: string, + leadName: string, + leadContext?: LeadContextUsage, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record + ): void { + const percent = leadContext?.percent; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[leadName], + finishedVisible?.[leadName] + ); + nodes.push({ + id: leadId, + kind: 'lead', + label: data.config.name || teamName, + state: !data.isAlive + ? 'idle' + : Object.keys(activeTools?.[leadName] ?? {}).length > 0 + ? 'tool_calling' + : 'active', + color: data.config.color ?? undefined, + contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, + avatarUrl: agentAvatarUrl(leadName, 64), + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[leadName] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 5) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), + domainRef: { kind: 'lead', teamName, memberName: leadName }, + }); + } + + #buildMemberNodes( + nodes: GraphNode[], + edges: GraphEdge[], + leadId: string, + data: TeamData, + teamName: string, + spawnStatuses?: Record, + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record + ): void { + for (const member of data.members) { + if (member.removedAt) continue; + if (isLeadMember(member)) continue; + + const memberId = `member:${teamName}:${member.name}`; + const spawn = spawnStatuses?.[member.name]; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[member.name], + finishedVisible?.[member.name] + ); + const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; + + nodes.push({ + id: memberId, + kind: 'member', + label: member.name, + state: hasRunningTool + ? 'tool_calling' + : TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), + color: member.color ?? undefined, + role: member.role ?? undefined, + spawnStatus: spawn?.status, + avatarUrl: agentAvatarUrl(member.name, 64), + currentTaskId: member.currentTaskId ?? undefined, + currentTaskSubject: member.currentTaskId + ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject + : undefined, + pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[member.name] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 5) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), + domainRef: { kind: 'member', teamName, memberName: member.name }, + }); + + edges.push({ + id: `edge:parent:${leadId}:${memberId}`, + source: leadId, + target: memberId, + type: 'parent-child', + }); + } + } + + #buildTaskNodes( + nodes: GraphNode[], + edges: GraphEdge[], + data: TeamData, + teamName: string, + commentReadState?: Record + ): void { + // Build lookup tables for fast resolution + const completedTaskIds = new Set(); + const taskDisplayIds = new Map(); + for (const t of data.tasks) { + if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id); + taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); + } + + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + const taskId = `task:${teamName}:${task.id}`; + const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + + // Task is blocked if any blockedBy task is still not completed + const isBlocked = + (task.blockedBy?.length ?? 0) > 0 && + task.blockedBy!.some((id) => !completedTaskIds.has(id)); + + // Resolve display IDs for dependencies + const blockedByDisplayIds = task.blockedBy?.length + ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + const blocksDisplayIds = task.blocks?.length + ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + + // Comment counts + const totalCommentCount = task.comments?.length ?? 0; + const unreadCommentCount = commentReadState + ? getUnreadCount( + commentReadState as Parameters[0], + teamName, + task.id, + task.comments ?? [] + ) + : 0; + + nodes.push({ + id: taskId, + kind: 'task', + label: task.displayId ?? `#${task.id.slice(0, 6)}`, + sublabel: task.subject, + state: TeamGraphAdapter.#mapTaskStatus(task.status), + taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), + reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), + displayId: task.displayId ?? undefined, + ownerId: ownerMemberId, + needsClarification: task.needsClarification ?? null, + isBlocked, + blockedByDisplayIds, + blocksDisplayIds, + totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, + unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, + domainRef: { kind: 'task', teamName, taskId: task.id }, + }); + + if (ownerMemberId) { + edges.push({ + id: `edge:own:${ownerMemberId}:${taskId}`, + source: ownerMemberId, + target: taskId, + type: 'ownership', + }); + } + + const seenBlockEdges = new Set(); + for (const blockedById of task.blockedBy ?? []) { + const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: `task:${teamName}:${blockedById}`, + target: taskId, + type: 'blocking', + }); + } + + for (const blocksId of task.blocks ?? []) { + const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: taskId, + target: `task:${teamName}:${blocksId}`, + type: 'blocking', + }); + } + + for (const relatedId of task.related ?? []) { + const key = [task.id, relatedId].sort().join(':'); + if (this.#seenRelated.has(key)) continue; + this.#seenRelated.add(key); + edges.push({ + id: `edge:rel:${key}`, + source: taskId, + target: `task:${teamName}:${relatedId}`, + type: 'related', + }); + } + } + } + + #buildProcessNodes( + nodes: GraphNode[], + edges: GraphEdge[], + data: TeamData, + teamName: string + ): void { + for (const proc of data.processes) { + if (proc.stoppedAt) continue; + const procId = `process:${teamName}:${proc.id}`; + const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null; + + nodes.push({ + id: procId, + kind: 'process', + label: proc.label, + state: 'active', + processUrl: proc.url ?? undefined, + processRegisteredBy: proc.registeredBy ?? undefined, + processCommand: proc.command ?? undefined, + processRegisteredAt: proc.registeredAt, + domainRef: { kind: 'process', teamName, processId: proc.id }, + }); + + if (ownerId) { + edges.push({ + id: `edge:proc:${ownerId}:${procId}`, + source: ownerId, + target: procId, + type: 'ownership', + }); + } + } + } + + #buildMessageParticles( + particles: GraphParticle[], + nodes: GraphNode[], + messages: readonly InboxMessage[], + teamName: string, + leadId: string, + leadName: string, + edges: GraphEdge[] + ): void { + const ordered = [...messages].reverse(); + + // First call: record all existing message IDs without creating particles. + // This prevents old messages from spawning particles when the graph opens. + if (!this.#initialMessagesSeen) { + this.#initialMessagesSeen = true; + for (const msg of ordered) { + const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); + this.#seenMessageIds.add(msgKey); + } + // Still create ghost nodes for cross-team (without particles) + for (const msg of ordered) { + if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') { + TeamGraphAdapter.#ensureCrossTeamNode(nodes, edges, msg, teamName, leadId); + } + } + return; + } + + // Track which ghost nodes we've already created this cycle + const seenGhostTeams = new Set(); + + // Subsequent calls: only create particles for messages not yet seen. + for (const msg of ordered) { + const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); + if (this.#seenMessageIds.has(msgKey)) continue; + this.#seenMessageIds.add(msgKey); + + // Skip comment notifications — #buildCommentParticles handles them with real text + if (msg.summary?.startsWith('Comment on ')) continue; + + // Handle noise messages: idle shows as "idle", others (shutdown, terminated) skip entirely + const msgText = msg.text ?? ''; + const noiseType = getInboxJsonType(msgText); + if (noiseType === 'idle_notification') { + // Show idle as a simple label, don't skip + } else if (isInboxNoiseMessage(msgText)) { + continue; // skip shutdown_approved, teammate_terminated, shutdown_request + } + + // Cross-team messages: create ghost node + edge + particle + if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') { + const ghostNodeId = TeamGraphAdapter.#ensureCrossTeamNode( + nodes, + edges, + msg, + teamName, + leadId + ); + if (!ghostNodeId) continue; + + const edgeId = edges.find( + (e) => + (e.source === ghostNodeId && e.target === leadId) || + (e.source === leadId && e.target === ghostNodeId) + )?.id; + if (!edgeId) continue; + + // incoming = from external team → lead (reverse on lead→ghost edge) + // sent = from lead → external team (forward on lead→ghost edge) + const isIncoming = msg.source === 'cross_team'; + const cleanText = stripCrossTeamPrefix(msg.text ?? ''); + const label = TeamGraphAdapter.#buildParticleLabel(msg.summary ?? cleanText, 'inbox'); + + particles.push({ + id: `particle:msg:${teamName}:${msgKey}`, + edgeId, + progress: 0, + kind: 'inbox_message', + color: '#cc88ff', + label, + reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse + }); + continue; + } + + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); + if (!edgeId) continue; + + // Determine direction: messages FROM a teammate TO lead should reverse + // (edges are always lead→member, but message goes member→lead) + const fromId = TeamGraphAdapter.#resolveParticipantId( + msg.from ?? '', + teamName, + leadId, + leadName + ); + const isFromTeammate = fromId !== leadId; + + // For idle notifications, show a clean "idle" label instead of raw JSON + const particleLabel = + noiseType === 'idle_notification' + ? 'idle' + : TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox'); + + particles.push({ + id: `particle:msg:${teamName}:${msgKey}`, + edgeId, + progress: 0, + kind: 'inbox_message', + color: msg.color ?? '#66ccff', + label: particleLabel, + reverse: isFromTeammate, + }); + } + + // Also ensure ghost nodes exist for ALL cross-team messages (not just new ones) + for (const msg of ordered) { + if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') { + const extTeam = TeamGraphAdapter.#extractExternalTeamName(msg.from ?? ''); + if (extTeam && !seenGhostTeams.has(extTeam)) { + seenGhostTeams.add(extTeam); + TeamGraphAdapter.#ensureCrossTeamNode(nodes, edges, msg, teamName, leadId); + } + } + } + } + + #buildCommentParticles( + particles: GraphParticle[], + data: TeamData, + teamName: string, + leadId: string, + leadName: string, + edges: GraphEdge[] + ): void { + // First call: record current comment counts without creating particles. + // This prevents pre-existing comments from spawning particles when the graph opens. + if (!this.#initialCommentsSeen) { + this.#initialCommentsSeen = true; + for (const task of data.tasks) { + this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); + } + return; + } + + // Build a member color lookup for assigning particle colors + const memberColors = new Map(); + for (const member of data.members) { + if (member.color) memberColors.set(member.name, member.color); + } + + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + + const prevCount = this.#seenCommentCounts.get(task.id) ?? 0; + const currentCount = task.comments?.length ?? 0; + + if (currentCount > prevCount) { + for (let index = prevCount; index < currentCount; index += 1) { + const newComment = task.comments?.[index]; + if (!newComment) continue; + const authorNodeId = TeamGraphAdapter.#resolveParticipantId( + newComment.author, + teamName, + leadId, + leadName + ); + const taskNodeId = `task:${teamName}:${task.id}`; + const authorEdge = + edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ?? + edges.find((e) => e.source === taskNodeId && e.target === authorNodeId); + + const edgeId = + authorEdge?.id ?? + (() => { + const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`; + if (!edges.some((edge) => edge.id === syntheticEdgeId)) { + edges.push({ + id: syntheticEdgeId, + source: authorNodeId, + target: taskNodeId, + type: 'message', + }); + } + return syntheticEdgeId; + })(); + + if (authorNodeId) { + particles.push({ + id: `particle:comment:${teamName}:${task.id}:${index + 1}`, + edgeId, + progress: 0, + kind: 'task_comment', + color: memberColors.get(newComment.author) ?? '#cc88ff', + label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'), + }); + } + } + } + + this.#seenCommentCounts.set(task.id, currentCount); + } + } + + // ─── Static mappers ────────────────────────────────────────────────────── + + static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { + if (spawnStatus === 'spawning') return 'thinking'; + if (spawnStatus === 'error') return 'error'; + if (spawnStatus === 'waiting') return 'waiting'; + switch (status) { + case 'active': + return 'active'; + case 'idle': + return 'idle'; + case 'terminated': + return 'terminated'; + default: + return 'idle'; + } + } + + static #mapTaskStatus(status: string): GraphNodeState { + switch (status) { + case 'pending': + return 'waiting'; + case 'in_progress': + return 'active'; + case 'completed': + return 'complete'; + default: + return 'idle'; + } + } + + static #mapTaskStatusLiteral( + status: string + ): 'pending' | 'in_progress' | 'completed' | 'deleted' { + switch (status) { + case 'pending': + return 'pending'; + case 'in_progress': + return 'in_progress'; + case 'completed': + return 'completed'; + case 'deleted': + return 'deleted'; + default: + return 'pending'; + } + } + + static #mapReviewState(state: string | undefined): 'none' | 'review' | 'needsFix' | 'approved' { + switch (state) { + case 'review': + return 'review'; + case 'needsFix': + return 'needsFix'; + case 'approved': + return 'approved'; + default: + return 'none'; + } + } + + static #resolveMessageEdge( + msg: InboxMessage, + teamName: string, + leadId: string, + leadName: string, + edges: GraphEdge[] + ): string | null { + const { from, to } = msg; + + if (from && to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName); + return ( + edges.find((e) => e.source === fromId && e.target === toId)?.id ?? + edges.find((e) => e.source === toId && e.target === fromId)?.id ?? + null + ); + } + + if (from && !to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + return ( + edges.find( + (e) => + (e.source === leadId && e.target === fromId) || + (e.source === fromId && e.target === leadId) + )?.id ?? null + ); + } + + return null; + } + + static #resolveParticipantId( + name: string, + teamName: string, + leadId: string, + leadName?: string + ): string { + const normalized = name.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') return leadId; + if (leadName && normalized === leadName.trim().toLowerCase()) return leadId; + return `member:${teamName}:${name}`; + } + + /** Extract external team name from cross-team "from" field like "team-b.alice" */ + static #extractExternalTeamName(from: string): string | null { + const dotIdx = from.indexOf('.'); + if (dotIdx <= 0) return null; + return from.slice(0, dotIdx); + } + + /** Create or find ghost node + edge for an external team. Returns ghost node ID. */ + static #ensureCrossTeamNode( + nodes: GraphNode[], + edges: GraphEdge[], + msg: InboxMessage, + teamName: string, + leadId: string + ): string | null { + const extTeam = TeamGraphAdapter.#extractExternalTeamName(msg.from ?? ''); + if (!extTeam) return null; + + const ghostId = `crossteam:${extTeam}`; + + // Create ghost node if not exists + if (!nodes.some((n) => n.id === ghostId)) { + nodes.push({ + id: ghostId, + kind: 'crossteam', + label: extTeam, + state: 'active', + color: '#cc88ff', + domainRef: { kind: 'crossteam', teamName, externalTeamName: extTeam }, + }); + } + + // Create edge ghost↔lead if not exists + const edgeId = `edge:crossteam:${ghostId}:${leadId}`; + if (!edges.some((e) => e.id === edgeId)) { + edges.push({ + id: edgeId, + source: ghostId, + target: leadId, + type: 'message', + }); + } + + return ghostId; + } + + static #buildParticleLabel( + text: string | undefined, + kind: 'inbox' | 'comment', + max = 52 + ): string | undefined { + const normalized = text?.replace(/\s+/g, ' ').trim(); + const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}'; + if (!normalized) return prefix; + const clipped = + normalized.length > max + ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026` + : normalized; + return `${prefix} ${clipped}`; + } + + static #getMessageParticleKey(msg: InboxMessage): string { + if (msg.messageId && msg.messageId.trim().length > 0) { + return msg.messageId; + } + return [msg.timestamp, msg.from ?? '', msg.to ?? '', msg.summary ?? '', msg.text ?? ''].join( + '\u0000' + ); + } +} diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts new file mode 100644 index 00000000..9c755038 --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -0,0 +1,74 @@ +/** + * React hook bridge for TeamGraphAdapter class. + * Thin wrapper — instantiates the class adapter and calls adapt() with store data. + */ + +import { useMemo, useRef, useSyncExternalStore } from 'react'; + +import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import { TeamGraphAdapter } from './TeamGraphAdapter'; + +import type { GraphDataPort } from '@claude-teams/agent-graph'; + +export function useTeamGraphAdapter(teamName: string): GraphDataPort { + const adapterRef = useRef(TeamGraphAdapter.create()); + + const { + teamData, + spawnStatuses, + leadContext, + pendingApprovals, + activeTools, + finishedVisible, + toolHistory, + } = useStore( + useShallow((s) => ({ + teamData: s.selectedTeamData, + spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, + pendingApprovals: s.pendingApprovals, + activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, + finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, + toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, + })) + ); + + const pendingApprovalAgents = useMemo(() => { + const agents = new Set(); + for (const a of pendingApprovals) { + if (a.source !== 'lead') agents.add(a.source); + } + return agents; + }, [pendingApprovals]); + + const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + + return useMemo( + () => + adapterRef.current.adapt( + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory, + commentReadState + ), + [ + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory, + commentReadState, + ] + ); +} diff --git a/src/renderer/features/agent-graph/index.ts b/src/renderer/features/agent-graph/index.ts new file mode 100644 index 00000000..1c15bd65 --- /dev/null +++ b/src/renderer/features/agent-graph/index.ts @@ -0,0 +1,9 @@ +/** + * agent-graph feature — public API. + * Only exports UI components and adapter types. + */ + +export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; +export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; +export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx new file mode 100644 index 00000000..11f8ed5b --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -0,0 +1,385 @@ +/** + * GraphNodePopover — renders popover for graph nodes using project UI components. + * Lives in features/ (not in package) so it CAN import from @renderer/. + * Reuses agentAvatarUrl, status helpers, and UI primitives from the project. + */ + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +import { GraphTaskCard } from './GraphTaskCard'; + +// ─── Tool name/preview formatters ─────────────────────────────────────────── + +/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */ +function formatToolName(raw: string): string { + // Strip MCP prefixes (mcp__serverName__toolName → toolName) + const parts = raw.split('__'); + const name = parts[parts.length - 1] ?? raw; + // snake_case → Title Case + return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Clean up tool preview: strip raw JSON, extract meaningful part */ +function formatToolPreview(preview: string | undefined): string | undefined { + if (!preview) return undefined; + // If it looks like raw JSON object, try to extract a readable field + if (preview.startsWith('{') || preview.startsWith('[')) { + try { + const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview); + // Common readable fields + return ( + obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined + ); + } catch { + // Truncated JSON — extract first quoted value + const match = preview.match(/"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/); + if (match) return match[1]; + } + } + return preview.length > 50 ? preview.slice(0, 50) + '...' : preview; +} + +interface GraphNodePopoverProps { + node: GraphNode; + teamName: string; + onClose: () => void; + onSendMessage?: (memberName: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; + onCreateTask?: (owner: string) => void; + onStartTask?: (taskId: string) => void; + onCompleteTask?: (taskId: string) => void; + onApproveTask?: (taskId: string) => void; + onRequestReview?: (taskId: string) => void; + onRequestChanges?: (taskId: string) => void; + onCancelTask?: (taskId: string) => void; + onMoveBackToDone?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +export const GraphNodePopover = ({ + node, + teamName, + onClose, + onSendMessage, + onOpenTaskDetail, + onOpenMemberProfile, + onCreateTask, + onStartTask, + onCompleteTask, + onApproveTask, + onRequestReview, + onRequestChanges, + onCancelTask, + onMoveBackToDone, + onDeleteTask, +}: GraphNodePopoverProps): React.JSX.Element => { + if (node.kind === 'member' || node.kind === 'lead') { + return ( + + ); + } + + if (node.kind === 'task') { + return ( + + ); + } + + // Cross-team ghost node + if (node.kind === 'crossteam') { + const extTeamName = + node.domainRef.kind === 'crossteam' ? node.domainRef.externalTeamName : node.label; + return ( +
+
+ {'\u{2194}'} + {extTeamName} +
+
External team
+
+ ); + } + + // Process + return ( +
+
{node.label}
+ {node.processCommand && ( +
+ $ {node.processCommand} +
+ )} +
+ {node.processRegisteredBy && ( +
+ Started by: {node.processRegisteredBy} +
+ )} + {node.processRegisteredAt && ( +
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
+ )} +
+ {node.processUrl && ( + + Open URL + + )} +
+ ); +}; + +// ─── Member Popover ───────────────────────────────────────────────────────── + +const MemberPopoverContent = ({ + node, + onClose, + onSendMessage, + onOpenProfile, + onCreateTask, + onOpenTask, +}: { + node: GraphNode; + onClose: () => void; + onSendMessage?: (name: string) => void; + onOpenProfile?: (name: string) => void; + onCreateTask?: (owner: string) => void; + onOpenTask?: (taskId: string) => void; +}): React.JSX.Element => { + const memberName = + node.domainRef.kind === 'member' || node.domainRef.kind === 'lead' + ? node.domainRef.memberName + : 'team-lead'; + const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); + const statusLabel = + node.state === 'active' + ? 'Active' + : node.state === 'idle' + ? 'Idle' + : node.state === 'terminated' + ? 'Offline' + : node.state === 'tool_calling' + ? 'Running tool' + : node.state; + + const statusDotColor = + node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling' + ? 'bg-emerald-400' + : node.state === 'idle' + ? 'bg-zinc-400' + : node.state === 'error' + ? 'bg-red-400' + : 'bg-zinc-600'; + + return ( +
+ {/* Header: avatar + name */} +
+
+ {memberName} +
+
+
+
+ {node.label.split(' · ')[0]} +
+ {node.role && ( +
{node.role}
+ )} +
+
+ + {/* Status badges */} +
+ + {statusLabel} + + {node.kind === 'lead' && ( + + Lead + + )} + {node.spawnStatus && node.spawnStatus !== 'online' && ( + + {node.spawnStatus} + + )} +
+ + {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} + + {/* Current task indicator — reuses same pattern as MemberCard */} + {node.currentTaskId && node.currentTaskSubject && ( +
+ + working on + +
+ )} + + {node.activeTool && ( +
+
+ + + {node.activeTool.state === 'running' + ? 'Running tool' + : node.activeTool.state === 'error' + ? 'Tool failed' + : 'Tool finished'} + +
+
+ {node.activeTool.preview + ? `${node.activeTool.name}: ${node.activeTool.preview}` + : node.activeTool.name} +
+ {node.activeTool.resultPreview && node.activeTool.state !== 'running' && ( +
+ {node.activeTool.resultPreview} +
+ )} +
+ )} + + {node.recentTools && node.recentTools.length > 0 && ( +
+
+ Recent tools +
+
+ {node.recentTools.slice(0, 5).map((tool) => { + const shortName = formatToolName(tool.name); + const shortPreview = formatToolPreview(tool.preview); + return ( +
+ + + {shortName} + + {shortPreview && ( + {shortPreview} + )} +
+ ); + })} +
+
+ )} + + {/* Actions */} +
+ + + +
+
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx new file mode 100644 index 00000000..3a2f7a4b --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -0,0 +1,150 @@ +/** + * GraphTaskCard — wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects. + * Lives in features/ so it CAN import from @renderer/. + */ + +import { useMemo } from 'react'; + +import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; +import { useStore } from '@renderer/store'; + +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface GraphTaskCardProps { + node: GraphNode; + teamName: string; + onClose: () => void; + onOpenDetail?: (taskId: string) => void; + onStartTask?: (taskId: string) => void; + onCompleteTask?: (taskId: string) => void; + onApproveTask?: (taskId: string) => void; + onRequestReview?: (taskId: string) => void; + onRequestChanges?: (taskId: string) => void; + onCancelTask?: (taskId: string) => void; + onMoveBackToDone?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function resolveColumn(task: TeamTask): KanbanColumnId { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + if (task.status === 'in_progress') return 'in_progress'; + if (task.status === 'completed') return 'done'; + return 'todo'; +} + +function getGlowStyle(task: TeamTask): React.CSSProperties { + const col = resolveColumn(task); + const blocked = (task.blockedBy?.length ?? 0) > 0; + if (blocked) { + return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }; + } + switch (col) { + case 'in_progress': + return { + boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)', + }; + case 'review': + return task.reviewState === 'needsFix' + ? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' } + : { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' }; + case 'approved': + return { boxShadow: '0 0 10px rgba(34, 197, 94, 0.3)' }; + default: + return {}; + } +} + +function getPulseClass(task: TeamTask): string { + const col = resolveColumn(task); + if (col === 'in_progress' || col === 'review') return 'animate-pulse'; + return ''; +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +export const GraphTaskCard = ({ + node, + teamName, + onClose, + onOpenDetail, + onStartTask, + onCompleteTask, + onApproveTask, + onRequestReview, + onRequestChanges, + onCancelTask, + onMoveBackToDone, + onDeleteTask, +}: GraphTaskCardProps): React.JSX.Element => { + const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; + + const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId)); + const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []); + const members = useStore((s) => s.selectedTeamData?.members ?? []); + + const taskMap = useMemo(() => { + const map = new Map(); + for (const t of tasks) map.set(t.id, t); + return map; + }, [tasks]); + + const memberColorMap = useMemo(() => { + const map = new Map(); + for (const m of members) { + if (m.color) map.set(m.name, m.color); + } + return map; + }, [members]); + + if (!task) { + return ( +
+
+ {node.displayId ?? node.label} +
+
+ ); + } + + const columnId = resolveColumn(task); + const taskWithKanban = task as TeamTaskWithKanban; + + const closeAct = (fn?: (id: string) => void) => (taskId: string) => { + fn?.(taskId); + onClose(); + }; + + return ( +
+ { + onOpenDetail?.(taskId); + onClose(); + }} + onStartTask={closeAct(onStartTask)} + onCompleteTask={closeAct(onCompleteTask)} + onApprove={closeAct(onApproveTask)} + onRequestReview={closeAct(onRequestReview)} + onRequestChanges={closeAct(onRequestChanges)} + onCancelTask={closeAct(onCancelTask)} + onMoveBackToDone={closeAct(onMoveBackToDone)} + onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined} + /> +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx new file mode 100644 index 00000000..84fe1f8b --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -0,0 +1,110 @@ +/** + * TeamGraphOverlay — full-screen overlay showing the agent graph. + * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). + */ + +import { useCallback, useMemo } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; +import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import { GraphNodePopover } from './GraphNodePopover'; + +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; + +export interface TeamGraphOverlayProps { + teamName: string; + onClose: () => void; + onPinAsTab?: () => void; + onSendMessage?: (memberName: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; +} + +export const TeamGraphOverlay = ({ + teamName, + onClose, + onPinAsTab, + onSendMessage, + onOpenTaskDetail, + onOpenMemberProfile, +}: TeamGraphOverlayProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + + // Task action dispatchers (same pattern as TeamGraphTab) + const dispatchTaskAction = useCallback( + (action: string) => (taskId: string) => + window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })), + [teamName] + ); + const taskActions = useMemo( + () => ({ + onStartTask: dispatchTaskAction('start-task'), + onCompleteTask: dispatchTaskAction('complete-task'), + onApproveTask: dispatchTaskAction('approve-task'), + onRequestReview: dispatchTaskAction('request-review'), + onRequestChanges: dispatchTaskAction('request-changes'), + onCancelTask: dispatchTaskAction('cancel-task'), + onMoveBackToDone: dispatchTaskAction('move-back-to-done'), + onDeleteTask: dispatchTaskAction('delete-task'), + }), + [dispatchTaskAction] + ); + + const events: GraphEventPort = { + onNodeDoubleClick: useCallback( + (ref: GraphDomainRef) => { + if (ref.kind === 'task') onOpenTaskDetail?.(ref.taskId); + else if (ref.kind === 'member') onOpenMemberProfile?.(ref.memberName); + }, + [onOpenTaskDetail, onOpenMemberProfile] + ), + onSendMessage: useCallback( + (memberName: string) => onSendMessage?.(memberName), + [onSendMessage] + ), + onOpenTaskDetail: useCallback( + (taskId: string) => onOpenTaskDetail?.(taskId), + [onOpenTaskDetail] + ), + onOpenMemberProfile: useCallback( + (memberName: string) => onOpenMemberProfile?.(memberName), + [onOpenMemberProfile] + ), + }; + + return ( +
+ + ( + { + onSendMessage?.(name); + closePopover(); + }} + onOpenTaskDetail={(id) => { + onOpenTaskDetail?.(id); + closePopover(); + }} + onOpenMemberProfile={(name) => { + onOpenMemberProfile?.(name); + closePopover(); + }} + {...taskActions} + /> + )} + /> +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx new file mode 100644 index 00000000..2f3c5370 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -0,0 +1,153 @@ +/** + * TeamGraphTab — wraps GraphView for use as a dedicated tab. + * Provides Fullscreen button that opens the overlay. + */ + +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; +import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import { GraphNodePopover } from './GraphNodePopover'; + +import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; + +const TeamGraphOverlay = lazy(() => + import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay })) +); + +export interface TeamGraphTabProps { + teamName: string; + isActive?: boolean; + isPaneFocused?: boolean; +} + +export const TeamGraphTab = ({ + teamName, + isActive = true, + isPaneFocused = false, +}: TeamGraphTabProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + const [fullscreen, setFullscreen] = useState(false); + + // Typed event dispatchers (DRY — used in both events + renderOverlay) + const dispatchOpenTask = useCallback( + (taskId: string) => + window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })), + [teamName] + ); + const dispatchSendMessage = useCallback( + (memberName: string) => + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName } }) + ), + [teamName] + ); + const dispatchOpenProfile = useCallback( + (memberName: string) => + window.dispatchEvent( + new CustomEvent('graph:open-profile', { detail: { teamName, memberName } }) + ), + [teamName] + ); + const dispatchCreateTask = useCallback( + (owner: string) => + window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })), + [teamName] + ); + + // Task action dispatchers + const dispatchTaskAction = useCallback( + (action: string) => (taskId: string) => + window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })), + [teamName] + ); + const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]); + const dispatchCompleteTask = useMemo( + () => dispatchTaskAction('complete-task'), + [dispatchTaskAction] + ); + const dispatchApproveTask = useMemo( + () => dispatchTaskAction('approve-task'), + [dispatchTaskAction] + ); + const dispatchRequestReview = useMemo( + () => dispatchTaskAction('request-review'), + [dispatchTaskAction] + ); + const dispatchRequestChanges = useMemo( + () => dispatchTaskAction('request-changes'), + [dispatchTaskAction] + ); + const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]); + const dispatchMoveBackToDone = useMemo( + () => dispatchTaskAction('move-back-to-done'), + [dispatchTaskAction] + ); + const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]); + + const events: GraphEventPort = { + onNodeDoubleClick: useCallback( + (ref: GraphDomainRef) => { + if (ref.kind === 'task') dispatchOpenTask(ref.taskId); + else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName); + }, + [dispatchOpenTask, dispatchOpenProfile] + ), + onSendMessage: dispatchSendMessage, + onOpenTaskDetail: dispatchOpenTask, + onOpenMemberProfile: dispatchOpenProfile, + }; + + return ( +
+ +
+ setFullscreen(true)} + renderOverlay={({ node, onClose }) => ( + + )} + /> +
+ {fullscreen && ( + + setFullscreen(false)} + onSendMessage={dispatchSendMessage} + onOpenTaskDetail={dispatchOpenTask} + onOpenMemberProfile={dispatchOpenProfile} + /> + + )} +
+ ); +}; diff --git a/src/renderer/hooks/useBranchSync.ts b/src/renderer/hooks/useBranchSync.ts index db802b26..5b7c9ea0 100644 --- a/src/renderer/hooks/useBranchSync.ts +++ b/src/renderer/hooks/useBranchSync.ts @@ -1,60 +1,37 @@ /** - * Centralized git branch polling hook. + * Centralized git branch sync hook. * * Provides two modes: * - `live: false` (default) — one-shot fetch on mount / path change - * - `live: true` — continuous polling with ref-counted shared timer + * - `live: true` — background tracking in main with ref-counted subscriptions * * Data is stored in the Zustand store (`branchByPath`) so any component * can read it via `useStore(s => s.branchByPath)`. * - * The module-level polling manager guarantees: - * - A single shared `setInterval` across all live subscribers - * - Deduplication: N components subscribing to the same path = 1 poll - * - Automatic cleanup: timer stops when all subscribers unmount + * The module-level tracking manager guarantees: + * - Deduplication: N components subscribing to the same path = 1 background tracker + * - Automatic cleanup: tracking stops when all subscribers unmount */ import { useEffect, useMemo } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; // ============================================================================= -// Constants -// ============================================================================= - -const POLL_INTERVAL_MS = 6_000; - -// ============================================================================= -// Module-level polling manager (singleton, outside React lifecycle) +// Module-level tracking manager (singleton, outside React lifecycle) // ============================================================================= const livePaths = new Map(); -let pollTimer: ReturnType | null = null; - -function startPollingIfNeeded(): void { - if (pollTimer || livePaths.size === 0) return; - pollTimer = setInterval(() => { - const paths = Array.from(livePaths.values()).map((v) => v.actualPath); - void useStore.getState().fetchBranches(paths); - }, POLL_INTERVAL_MS); -} - -function stopPollingIfEmpty(): void { - if (pollTimer && livePaths.size === 0) { - clearInterval(pollTimer); - pollTimer = null; - } -} - function subscribe(normalizedKey: string, actualPath: string): void { const entry = livePaths.get(normalizedKey); if (entry) { entry.refCount++; } else { livePaths.set(normalizedKey, { actualPath, refCount: 1 }); + void api.teams?.setProjectBranchTracking?.(actualPath, true).catch(() => undefined); } - startPollingIfNeeded(); } function unsubscribe(normalizedKey: string): void { @@ -63,8 +40,8 @@ function unsubscribe(normalizedKey: string): void { entry.refCount--; if (entry.refCount <= 0) { livePaths.delete(normalizedKey); + void api.teams?.setProjectBranchTracking?.(entry.actualPath, false).catch(() => undefined); } - stopPollingIfEmpty(); } // ============================================================================= @@ -75,7 +52,7 @@ function unsubscribe(normalizedKey: string): void { * Sync git branch data for the given project paths into the store. * * @param paths - Raw project paths to resolve branches for - * @param options.live - When true, keeps polling every 6s while mounted + * @param options.live - When true, enables main-side branch tracking while mounted */ export function useBranchSync(paths: string[], options?: { live?: boolean }): void { const live = options?.live ?? false; diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 679d5e23..a7831d6d 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -29,6 +29,7 @@ export function useCliInstaller(): { installerRawChunks: string[]; completedVersion: string | null; fetchCliStatus: () => Promise; + invalidateCliStatus: () => Promise; installCli: () => void; isBusy: boolean; } { @@ -44,6 +45,7 @@ export function useCliInstaller(): { const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const completedVersion = useStore((s) => s.cliCompletedVersion); const fetchCliStatus = useStore((s) => s.fetchCliStatus); + const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); const installCli = useStore((s) => s.installCli); const isBusy = installerState !== 'idle' && installerState !== 'error'; @@ -61,6 +63,7 @@ export function useCliInstaller(): { installerRawChunks, completedVersion, fetchCliStatus, + invalidateCliStatus, installCli, isBusy, }; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index ae763dd1..007a01aa 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -174,6 +174,18 @@ export function useKeyboardShortcuts(): void { return; } + // Cmd+Shift+G: Toggle team graph overlay + if (key === 'g' && event.shiftKey && !event.altKey) { + event.preventDefault(); + const activeTab = openTabs.find((t) => t.id === activeTabId); + if (activeTab?.type === 'team' && activeTab.teamName) { + window.dispatchEvent( + new CustomEvent('toggle-team-graph', { detail: { teamName: activeTab.teamName } }) + ); + } + return; + } + // Cmd+W: Close selected tabs (if multi-selected) or active tab if (key === 'w' && !event.altKey) { event.preventDefault(); diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index a273bc69..b15bcbcb 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -15,6 +15,8 @@ interface UseMentionDetectionOptions { triggerChars?: string[]; /** Enable or disable individual triggers dynamically. */ isTriggerEnabled?: (triggerChar: string) => boolean; + /** Additional validation for trigger matches before opening the dropdown. */ + isTriggerMatchValid?: (trigger: MentionTrigger, text: string) => boolean; } export interface DropdownPosition { @@ -176,6 +178,7 @@ export function useMentionDetection({ textareaRef, triggerChars = ['@'], isTriggerEnabled, + isTriggerMatchValid, }: UseMentionDetectionOptions): UseMentionDetectionResult { const [isOpen, setIsOpen] = useState(false); const [activeTriggerChar, setActiveTriggerChar] = useState(null); @@ -253,7 +256,8 @@ export function useMentionDetection({ (cursorPos: number) => { const trigger = findMentionTrigger(value, cursorPos, triggerChars); const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; - if (trigger && isEnabled) { + const isValid = trigger ? (isTriggerMatchValid?.(trigger, value) ?? true) : false; + if (trigger && isEnabled && isValid) { const sameQuery = triggerIndexRef.current === trigger.triggerIndex && activeTriggerCharRef.current === trigger.triggerChar && @@ -274,7 +278,7 @@ export function useMentionDetection({ dismiss(); } }, - [value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] + [value, triggerChars, isTriggerEnabled, isTriggerMatchValid, dismiss, computeDropdownPosition] ); const handleChange = useCallback( @@ -286,7 +290,8 @@ export function useMentionDetection({ const cursorPos = e.target.selectionStart; const trigger = findMentionTrigger(newValue, cursorPos, triggerChars); const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; - if (trigger && isEnabled) { + const isValid = trigger ? (isTriggerMatchValid?.(trigger, newValue) ?? true) : false; + if (trigger && isEnabled && isValid) { triggerIndexRef.current = trigger.triggerIndex; activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; @@ -300,7 +305,14 @@ export function useMentionDetection({ dismiss(); } }, - [onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] + [ + onValueChange, + triggerChars, + isTriggerEnabled, + isTriggerMatchValid, + dismiss, + computeDropdownPosition, + ] ); const handleSelect = useCallback( diff --git a/src/renderer/hooks/useViewportCommentRead.ts b/src/renderer/hooks/useViewportCommentRead.ts index e9014205..d3b22a45 100644 --- a/src/renderer/hooks/useViewportCommentRead.ts +++ b/src/renderer/hooks/useViewportCommentRead.ts @@ -17,6 +17,33 @@ interface UseViewportCommentReadOptions { scrollContainer: HTMLElement | null; } +const VISIBILITY_THRESHOLD = 0.1; + +export function getVisibleCommentIdsFallback( + scrollContainer: HTMLElement | null, + elementsById: ReadonlyMap +): string[] { + if (!scrollContainer || elementsById.size === 0) return []; + + const rootRect = scrollContainer.getBoundingClientRect(); + const visibleIds: string[] = []; + + for (const [commentId, element] of elementsById) { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + + const visibleWidth = Math.min(rect.right, rootRect.right) - Math.max(rect.left, rootRect.left); + const visibleHeight = Math.min(rect.bottom, rootRect.bottom) - Math.max(rect.top, rootRect.top); + + if (visibleWidth <= 0 || visibleHeight <= 0) continue; + if (visibleHeight / rect.height < VISIBILITY_THRESHOLD) continue; + + visibleIds.push(commentId); + } + + return visibleIds; +} + /** * Marks task comments as read based on viewport visibility. * @@ -45,6 +72,7 @@ export function useViewportCommentRead({ flush: () => void; } { const seenIdsRef = useRef>(new Set()); + const commentElementsRef = useRef>(new Map()); const teamNameRef = useRef(teamName); const taskIdRef = useRef(taskId); @@ -56,6 +84,7 @@ export function useViewportCommentRead({ // Reset tracked state when team/task changes useEffect(() => { seenIdsRef.current = new Set(); + commentElementsRef.current.clear(); }, [teamName, taskId]); const persistSeen = useCallback(() => { @@ -82,18 +111,37 @@ export function useViewportCommentRead({ const { registerElement } = useViewportObserver({ root: scrollContainer, - threshold: 0.1, + threshold: VISIBILITY_THRESHOLD, onVisibleChange: handleVisibleChange, }); const registerComment = useCallback( - (commentId: string) => registerElement(commentId), + (commentId: string) => { + const registerObservedElement = registerElement(commentId); + + return (el: HTMLElement | null) => { + if (el) { + commentElementsRef.current.set(commentId, el); + } else { + commentElementsRef.current.delete(commentId); + } + + registerObservedElement(el); + }; + }, [registerElement] ); const flush = useCallback(() => { + const fallbackVisibleIds = getVisibleCommentIdsFallback( + scrollContainer, + commentElementsRef.current + ); + for (const commentId of fallbackVisibleIds) { + seenIdsRef.current.add(commentId); + } persistSeen(); - }, [persistSeen]); + }, [persistSeen, scrollContainer]); return { registerComment, flush }; } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 28c9506e..27ef20db 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,6 +5,13 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; +import { normalizePath } from '@renderer/utils/pathNormalize'; +import { + buildTaskChangePresenceKey, + buildTaskChangeRequestOptions, + canDisplayTaskChangesForOptions, +} from '@renderer/utils/taskChangeRequest'; +import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; @@ -32,15 +39,24 @@ import { createUpdateSlice } from './slices/updateSlice'; import type { DetectedError } from '../types/data'; import type { AppState } from './types'; import type { + ActiveToolCall, CliInstallerProgress, LeadContextUsage, ScheduleChangeEvent, TeamChangeEvent, + ToolActivityEventPayload, ToolApprovalEvent, ToolApprovalRequest, UpdaterStatus, } from '@shared/types'; +const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; +const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; +const FINISHED_TOOL_DISPLAY_MS = 1_500; +const MAX_TOOL_HISTORY_PER_MEMBER = 6; +const CURRENT_APP_VERSION = + typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0'; + // ============================================================================= // Store Creation // ============================================================================= @@ -135,23 +151,218 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); + // This lightweight renderer-side poll keeps visible in-progress task badges fresh. + // It is intentionally independent from the backend log-source tracking feature flag below. + const inProgressChangePresencePollTimer = setInterval(() => { + void pollVisibleTeamInProgressChangePresence(); + }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); + cleanupFns.push(() => { + clearInterval(inProgressChangePresencePollTimer); + }); const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); let teamRefreshTimers = new Map>(); + let teamPresenceRefreshTimers = new Map>(); + let toolActivityTimers = new Map>(); + let inProgressChangePresencePollInFlight = false; + const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; const TEAM_REFRESH_THROTTLE_MS = 800; + const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const buildToolActivityTimerKey = ( + teamName: string, + memberName: string, + toolUseId: string, + kind: 'fade' + ): string => `${teamName}:${memberName}:${toolUseId}:${kind}`; + const clearToolActivityTimer = ( + teamName: string, + memberName: string, + toolUseId: string, + kind: 'fade' + ): void => { + const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind); + const existing = toolActivityTimers.get(key); + if (existing) { + clearTimeout(existing); + toolActivityTimers.delete(key); + } + }; + const scheduleToolActivityTimer = ( + teamName: string, + memberName: string, + toolUseId: string, + kind: 'fade', + delayMs: number, + cb: () => void + ): void => { + clearToolActivityTimer(teamName, memberName, toolUseId, kind); + const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind); + const timer = setTimeout(() => { + toolActivityTimers.delete(key); + cb(); + }, delayMs); + toolActivityTimers.set(key, timer); + }; + const clearToolActivityTimersForTeam = (teamName: string): void => { + for (const [key, timer] of toolActivityTimers.entries()) { + if (!key.startsWith(`${teamName}:`)) continue; + clearTimeout(timer); + toolActivityTimers.delete(key); + } + }; + const clearRuntimeToolStateForTeam = ( + prev: AppState, + teamName: string + ): Pick => { + const nextActive = { ...prev.activeToolsByTeam }; + const nextFinished = { ...prev.finishedVisibleByTeam }; + const nextHistory = { ...prev.toolHistoryByTeam }; + delete nextActive[teamName]; + delete nextFinished[teamName]; + delete nextHistory[teamName]; + return { + activeToolsByTeam: nextActive, + finishedVisibleByTeam: nextFinished, + toolHistoryByTeam: nextHistory, + }; + }; + const pushToolHistoryEntry = ( + history: Record>, + teamName: string, + entry: ActiveToolCall + ): Record> => { + const teamHistory = { ...(history[teamName] ?? {}) }; + const existing = teamHistory[entry.memberName] ?? []; + teamHistory[entry.memberName] = [ + entry, + ...existing.filter((t) => t.toolUseId !== entry.toolUseId), + ].slice(0, MAX_TOOL_HISTORY_PER_MEMBER); + return { ...history, [teamName]: teamHistory }; + }; + const upsertMemberToolEntry = ( + teamState: Record> | undefined, + entry: ActiveToolCall + ): Record> => ({ + ...(teamState ?? {}), + [entry.memberName]: { + ...((teamState ?? {})[entry.memberName] ?? {}), + [entry.toolUseId]: entry, + }, + }); + const removeMemberToolEntry = ( + teamState: Record> | undefined, + memberName: string, + toolUseId: string + ): Record> => { + if (!teamState?.[memberName]?.[toolUseId]) return teamState ?? {}; + const nextTeamState = { ...(teamState ?? {}) }; + const nextMemberState = { ...(nextTeamState[memberName] ?? {}) }; + delete nextMemberState[toolUseId]; + if (Object.keys(nextMemberState).length === 0) { + delete nextTeamState[memberName]; + } else { + nextTeamState[memberName] = nextMemberState; + } + return nextTeamState; + }; + const removeMemberToolGroup = ( + teamState: Record> | undefined, + memberName: string + ): Record> => { + if (!teamState?.[memberName]) return teamState ?? {}; + const nextTeamState = { ...(teamState ?? {}) }; + delete nextTeamState[memberName]; + return nextTeamState; + }; + const removeMemberToolEntries = ( + teamState: Record> | undefined, + memberName: string, + toolUseIds: readonly string[] + ): Record> => { + if (!teamState?.[memberName] || toolUseIds.length === 0) return teamState ?? {}; + let nextTeamState = teamState ?? {}; + let changed = false; + for (const toolUseId of toolUseIds) { + if (!nextTeamState[memberName]?.[toolUseId]) continue; + nextTeamState = removeMemberToolEntry(nextTeamState, memberName, toolUseId); + changed = true; + } + return changed ? nextTeamState : (teamState ?? {}); + }; const getBaseProjectId = (projectId: string | null | undefined): string | null => { if (!projectId) return null; const separatorIndex = projectId.indexOf('::'); return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId; }; + const pollVisibleTeamInProgressChangePresence = async (): Promise => { + if (inProgressChangePresencePollInFlight) { + return; + } + + const state = useStore.getState(); + const selectedTeamName = state.selectedTeamName; + const selectedTeamData = state.selectedTeamData; + if ( + !selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const candidateTasks = selectedTeamData.tasks.filter((task) => { + if (task.status !== 'in_progress') { + return false; + } + return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); + }); + if (candidateTasks.length === 0) { + inProgressChangePresenceCursorByTeam.delete(selectedTeamName); + return; + } + + inProgressChangePresencePollInFlight = true; + try { + const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; + const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); + const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; + const nextTask = sourceTasks[cursor % sourceTasks.length]; + + inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length); + + const current = useStore.getState(); + if ( + current.selectedTeamName !== selectedTeamName || + current.selectedTeamData?.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); + if (currentTask?.status !== 'in_progress') { + return; + } + + const requestOptions = buildTaskChangeRequestOptions(currentTask); + const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions); + current.invalidateTaskChangePresence([cacheKey]); + await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions); + } catch { + // Best-effort polling for in-progress tasks only. + } finally { + inProgressChangePresencePollInFlight = false; + } + }; + const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const key = `${projectId}/${sessionId}`; // Throttle (not trailing debounce): keep at most one pending refresh per session. @@ -257,6 +468,111 @@ export function initializeNotificationListeners(): () => void { }); }; + const getTrackedChangePresenceTeams = (): Set => { + const { selectedTeamName, selectedTeamData } = useStore.getState(); + if ( + !selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return new Set(); + } + return new Set([selectedTeamName]); + }; + + const getTrackedToolActivityTeams = (): Set => { + const { paneLayout } = useStore.getState(); + const tracked = new Set(); + for (const pane of paneLayout.panes) { + if (!pane.activeTabId) continue; + const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); + if (activeTab?.type === 'team' && activeTab.teamName) { + tracked.add(activeTab.teamName); + } + } + return tracked; + }; + + if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedChangePresenceTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if ( + state.paneLayout === prevState.paneLayout && + state.selectedTeamName === prevState.selectedTeamName && + state.selectedTeamData === prevState.selectedTeamData + ) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + trackedTeamNames.clear(); + }); + } + + if (api.teams?.setToolActivityTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedToolActivityTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setToolActivityTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if (state.paneLayout === prevState.paneLayout) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined); + } + trackedTeamNames.clear(); + }); + } + // Listen for task-list file changes to refresh currently viewed session metadata if (api.onTodoChange) { const cleanup = api.onTodoChange((event) => { @@ -362,7 +678,11 @@ export function initializeNotificationListeners(): () => void { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => { const isIgnoredRuntimeRun = (() => { if (!event.runId) return false; - return useStore.getState().ignoredProvisioningRunIds[event.runId] === event.teamName; + const state = useStore.getState(); + return ( + state.ignoredProvisioningRunIds[event.runId] === event.teamName || + state.ignoredRuntimeRunIds[event.runId] === event.teamName + ); })(); if (isIgnoredRuntimeRun) { return; @@ -383,6 +703,11 @@ export function initializeNotificationListeners(): () => void { ...prev.currentRuntimeRunIdByTeam, [event.teamName]: event.runId ?? null, }, + ignoredRuntimeRunIds: Object.fromEntries( + Object.entries(prev.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== event.teamName + ) + ), })); } }; @@ -415,8 +740,16 @@ export function initializeNotificationListeners(): () => void { if (nextActivity === 'offline') { nextState.leadContextByTeam = { ...prev.leadContextByTeam }; delete nextState.leadContextByTeam[event.teamName]; + Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName)); nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam }; delete nextState.currentRuntimeRunIdByTeam[event.teamName]; + nextState.ignoredRuntimeRunIds = event.runId + ? { + ...prev.ignoredRuntimeRunIds, + [event.runId]: event.teamName, + } + : prev.ignoredRuntimeRunIds; + clearToolActivityTimersForTeam(event.teamName); } return nextState as typeof prev; @@ -442,6 +775,135 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'tool-activity' && event.detail) { + if (isStaleRuntimeEvent) { + return; + } + seedCurrentRunIdIfMissing(); + try { + const payload = JSON.parse(event.detail) as ToolActivityEventPayload; + if (payload.action === 'start' && payload.activity) { + const activity: ActiveToolCall = { + memberName: payload.activity.memberName, + toolUseId: payload.activity.toolUseId, + toolName: payload.activity.toolName, + preview: payload.activity.preview, + startedAt: payload.activity.startedAt, + source: payload.activity.source, + state: 'running', + }; + + useStore.setState((prev) => ({ + activeToolsByTeam: { + ...prev.activeToolsByTeam, + [event.teamName]: upsertMemberToolEntry( + prev.activeToolsByTeam[event.teamName], + activity + ), + }, + })); + } else if (payload.action === 'finish' && payload.memberName && payload.toolUseId) { + const memberName = payload.memberName; + const toolUseId = payload.toolUseId; + useStore.setState((prev) => { + const current = prev.activeToolsByTeam[event.teamName]?.[memberName]?.[toolUseId]; + if (!current) { + return {}; + } + + const completed: ActiveToolCall = { + ...current, + state: payload.isError ? 'error' : 'complete', + finishedAt: payload.finishedAt ?? new Date().toISOString(), + resultPreview: payload.resultPreview, + }; + + scheduleToolActivityTimer( + event.teamName, + memberName, + toolUseId, + 'fade', + FINISHED_TOOL_DISPLAY_MS, + () => { + useStore.setState((state) => { + const nextCurrent = + state.finishedVisibleByTeam[event.teamName]?.[memberName]?.[toolUseId]; + if (!nextCurrent) { + return {}; + } + return { + finishedVisibleByTeam: { + ...state.finishedVisibleByTeam, + [event.teamName]: removeMemberToolEntry( + state.finishedVisibleByTeam[event.teamName], + memberName, + toolUseId + ), + }, + }; + }); + } + ); + + return { + activeToolsByTeam: { + ...prev.activeToolsByTeam, + [event.teamName]: removeMemberToolEntry( + prev.activeToolsByTeam[event.teamName], + memberName, + toolUseId + ), + }, + finishedVisibleByTeam: { + ...prev.finishedVisibleByTeam, + [event.teamName]: upsertMemberToolEntry( + prev.finishedVisibleByTeam[event.teamName], + completed + ), + }, + toolHistoryByTeam: pushToolHistoryEntry( + prev.toolHistoryByTeam, + event.teamName, + completed + ), + }; + }); + } else if (payload.action === 'reset') { + if (payload.memberName) { + const memberName = payload.memberName; + const toolUseIds = + Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0 + ? payload.toolUseIds + : null; + useStore.setState((prev) => { + if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) { + return {}; + } + return { + activeToolsByTeam: { + ...prev.activeToolsByTeam, + [event.teamName]: toolUseIds + ? removeMemberToolEntries( + prev.activeToolsByTeam[event.teamName], + memberName, + toolUseIds + ) + : removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName), + }, + }; + }); + } else { + useStore.setState((prev) => ({ + activeToolsByTeam: { ...prev.activeToolsByTeam, [event.teamName]: {} }, + })); + } + } + } catch { + /* ignore malformed detail */ + } + return; + } + // Member spawn status change: fetch updated spawn statuses for the team. if (event.type === 'member-spawn') { if (isStaleRuntimeEvent) { @@ -474,6 +936,22 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'log-source-change') { + if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { + return; + } + if (teamPresenceRefreshTimers.has(event.teamName)) { + return; + } + const timer = setTimeout(() => { + teamPresenceRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + void current.refreshSelectedTeamChangePresence(event.teamName); + }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); + teamPresenceRefreshTimers.set(event.teamName, timer); + return; + } + // Throttled refresh of summary list (keeps TeamListView current without flooding). if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { @@ -513,6 +991,10 @@ export function initializeNotificationListeners(): () => void { cleanup(); for (const t of teamRefreshTimers.values()) clearTimeout(t); teamRefreshTimers = new Map(); + for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); + teamPresenceRefreshTimers = new Map(); + for (const t of toolActivityTimers.values()) clearTimeout(t); + toolActivityTimers = new Map(); if (teamListRefreshTimer) { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; @@ -525,17 +1007,46 @@ export function initializeNotificationListeners(): () => void { } } + if (api.teams?.onProjectBranchChange) { + const cleanup = api.teams.onProjectBranchChange((_event: unknown, event) => { + if (!event?.projectPath) return; + const normalizedPath = normalizePath(event.projectPath); + if (!normalizedPath) return; + useStore.setState((prev) => { + const current = prev.branchByPath[normalizedPath]; + if (current === event.branch) { + return {}; + } + return { + branchByPath: { + ...prev.branchByPath, + [normalizedPath]: event.branch, + }, + }; + }); + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Tool approval events from CLI control_request protocol if (api.teams?.onToolApprovalEvent) { const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { const event = data as ToolApprovalEvent; if ('autoResolved' in event && event.autoResolved) { - // Timeout or auto-allow resolved in main — remove from UI - useStore.setState((s) => ({ - pendingApprovals: s.pendingApprovals.filter( - (a) => !(a.runId === event.runId && a.requestId === event.requestId) - ), - })); + // Timeout or auto-allow resolved in main — remove from UI and record result + const allowed = event.reason !== 'timeout_deny'; + useStore.setState((s) => { + const next = new Map(s.resolvedApprovals); + next.set(event.requestId, allowed); + return { + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.runId === event.runId && a.requestId === event.requestId) + ), + resolvedApprovals: next, + }; + }); } else if ('dismissed' in event && event.dismissed) { const dismiss = event; useStore.setState((s) => ({ @@ -556,7 +1067,8 @@ export function initializeNotificationListeners(): () => void { // Sync saved tool approval settings to main process on startup const savedSettings = useStore.getState().toolApprovalSettings; - api.teams.updateToolApprovalSettings?.(savedSettings).catch(() => { + const activeTeam = useStore.getState().selectedTeamName ?? '__global__'; + api.teams.updateToolApprovalSettings?.(activeTeam, savedSettings).catch(() => { // Silently ignore — settings will use defaults until next update }); } @@ -690,12 +1202,16 @@ export function initializeNotificationListeners(): () => void { if (currentStatus === 'downloading' || currentStatus === 'downloaded') { break; } + const nextVersion = s.version ? normalizeVersion(s.version) : null; + if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) { + break; + } const dismissed = useStore.getState().dismissedUpdateVersion; useStore.setState({ updateStatus: 'available', - availableVersion: s.version ?? null, + availableVersion: nextVersion, releaseNotes: s.releaseNotes ?? null, - showUpdateDialog: (s.version ?? null) !== dismissed, + showUpdateDialog: nextVersion !== dismissed, }); break; } @@ -714,10 +1230,15 @@ export function initializeNotificationListeners(): () => void { }); break; case 'downloaded': + if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) { + break; + } useStore.setState({ updateStatus: 'downloaded', downloadProgress: 100, - availableVersion: s.version ?? useStore.getState().availableVersion, + availableVersion: s.version + ? normalizeVersion(s.version) + : useStore.getState().availableVersion, }); break; case 'error': { diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index aceba6dd..98044fb2 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -16,6 +16,7 @@ const taskChangesPresenceRevalidationInFlight = new Set(); /** Negative results cached with timestamp — recheck after 30s */ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; +const TASK_CHANGE_WARM_CONCURRENCY = 4; const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); let latestTaskChangesRequestToken = 0; @@ -77,6 +78,16 @@ function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; } +function resolveTaskChangePresenceFromResult( + data: Pick +): 'has_changes' | 'no_changes' | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} + export interface ChangeReviewSlice { // Phase 1 state activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null; @@ -503,10 +514,14 @@ export const createChangeReviewSlice: StateCreator 0 }, }); + if (nextPresence) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); + } if (data.files.length > 0) { taskChangesNegativeCache.delete(cacheKey); } else { @@ -1310,12 +1325,20 @@ export const createChangeReviewSlice: StateCreator { + const selectedTask = + get().selectedTeamName === teamName + ? get().selectedTeamData?.tasks.find((task) => task.id === taskId) + : undefined; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); const summaryCacheable = isTaskSummaryCacheableForOptions(options); - if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return; + if (summaryCacheable && get().taskHasChanges[cacheKey] === true) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); + return; + } if (taskChangesCheckInFlight.has(cacheKey)) return; const negativeTs = taskChangesNegativeCache.get(cacheKey); - if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + const hasUnknownPresence = selectedTask?.changePresence === 'unknown'; + if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return; taskChangesCheckInFlight.add(cacheKey); try { @@ -1323,11 +1346,13 @@ export const createChangeReviewSlice: StateCreator 0) { set((s) => ({ taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, })); taskChangesNegativeCache.delete(cacheKey); + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); if (wasRestoredBeforeCurrentSession(data)) { void revalidateTaskChangePresence(teamName, taskId, options); } @@ -1336,6 +1361,11 @@ export const createChangeReviewSlice: StateCreator { - if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) - return; + const entries = [...uniqueRequests.entries()]; + const runWarmRequest = async ( + cacheKey: string, + request: { teamName: string; taskId: string; options: TaskChangeRequestOptions } + ): Promise => { + if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) { + return; + } - taskChangesCheckInFlight.add(cacheKey); - try { - const data = await api.review.getTaskChanges(request.teamName, request.taskId, { - ...request.options, - summaryOnly: true, - }); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - if (wasRestoredBeforeCurrentSession(data)) { - void revalidateTaskChangePresence( - request.teamName, - request.taskId, - request.options - ); - } - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(request.teamName, request.taskId, { + ...request.options, + summaryOnly: true, + }); + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); + if (data.files.length > 0) { + taskChangesNegativeCache.delete(cacheKey); + if (wasRestoredBeforeCurrentSession(data)) { + void revalidateTaskChangePresence(request.teamName, request.taskId, request.options); } - } catch { - // Best-effort warm path. - } finally { - taskChangesCheckInFlight.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); } - }) - ); + } catch { + // Best-effort warm path. + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }; + + for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) { + await Promise.all( + entries + .slice(index, index + TASK_CHANGE_WARM_CONCURRENCY) + .map(([cacheKey, request]) => runWarmRequest(cacheKey, request)) + ); + } }, invalidateTaskChangePresence: (cacheKeys) => { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 92b25fdb..929b5938 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -42,6 +42,7 @@ export interface CliInstallerSlice { // Actions fetchCliStatus: () => Promise; + invalidateCliStatus: () => Promise; installCli: () => void; } @@ -90,6 +91,10 @@ export const createCliInstallerSlice: StateCreator { + await api.cliInstaller?.invalidateStatus(); + }, + installCli: () => { set({ cliInstallerState: 'checking', diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index ec40e5db..218fcae9 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -4,7 +4,6 @@ import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; import type { AppState, SearchMatch } from '../types'; import type { AIGroupExpansionLevel } from '@renderer/types/groups'; @@ -17,6 +16,9 @@ import type { StateCreator } from 'zustand'; type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent'; +/** Maximum number of search matches to track. Beyond this, results are capped. */ +const MAX_SEARCH_MATCHES = 500; + const isSearchDebugEnabled = (): boolean => { if (typeof window === 'undefined') return false; try { @@ -57,6 +59,10 @@ export interface ConversationSlice { searchResultCount: number; currentSearchIndex: number; searchMatches: SearchMatch[]; + /** True when total matches exceeded the cap and results were truncated */ + searchResultsCapped: boolean; + /** Item IDs that contain at least one search match — used by components to skip re-renders */ + searchMatchItemIds: Set; // Auto-expand state for search results /** AI group IDs that should be expanded to show search results */ @@ -126,6 +132,8 @@ export const createConversationSlice: StateCreator { - const mdMatches = findMarkdownSearchMatches(text, lowerQuery); - for (const mdMatch of mdMatches) { + if (capped) return; + const lowerText = text.toLowerCase(); + let pos = 0; + let matchIndexInItem = 0; + while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) { + if (matches.length >= MAX_SEARCH_MATCHES) { + capped = true; + return; + } matches.push({ itemId, itemType, - matchIndexInItem: mdMatch.matchIndexInItem, + matchIndexInItem, globalIndex, displayItemId, }); + matchIndexInItem++; globalIndex++; + pos += lowerQuery.length; } }; for (const item of conversation.items) { + if (capped) break; if (item.type === 'user') { const raw = item.group.content.rawText ?? item.group.content.text ?? ''; const text = stripAgentBlocks(raw); - addMarkdownMatches(text, item.group.id, 'user'); + addPlainTextMatches(text, item.group.id, 'user'); } else if (item.type === 'ai') { - // For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) const aiGroup = item.group; const itemId = aiGroup.id; const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); if (lastOutput?.type === 'text' && lastOutput.text) { - // Last output text - displayItemId indicates this is lastOutput content - addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); + addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); } - // Skip tool_result type - only searching text output } - // Skip system items entirely } if (isSearchDebugEnabled()) { @@ -293,11 +309,19 @@ export const createConversationSlice: StateCreator(); + for (const match of matches) { + matchItemIds.add(match.itemId); + } + set({ searchQuery: query, searchResultCount: matches.length, currentSearchIndex: matches.length > 0 ? 0 : -1, searchMatches: matches, + searchResultsCapped: capped, + searchMatchItemIds: matchItemIds, }); }, @@ -373,10 +397,17 @@ export const createConversationSlice: StateCreator(); + for (const match of nextMatches) { + syncedMatchItemIds.add(match.itemId); + } + set({ searchMatches: nextMatches, searchResultCount: nextMatches.length, currentSearchIndex: newCurrentIndex, + searchMatchItemIds: syncedMatchItemIds, }); }, @@ -406,6 +437,8 @@ export const createConversationSlice: StateCreator = (set, ge for (const repo of state.repositoryGroups) { for (const wt of repo.worktrees) { - if (wt.sessions.includes(sessionId)) { + if (wt.id === projectId) { foundRepo = repo.id; foundWorktree = wt.id; break; @@ -372,11 +372,11 @@ export const createTabSlice: StateCreator = (set, ge } } - // For team tabs, re-select the team so global selectedTeamData matches this tab. + // For team and graph tabs, re-select the team so global selectedTeamData matches this tab. // Without this, switching between team A and team B tabs leaves stale data // because each TeamDetailView is kept mounted (CSS display-toggle) and its // useEffect(teamName) only fires once on mount. - if (tab.type === 'team' && tab.teamName) { + if ((tab.type === 'team' || tab.type === 'graph') && tab.teamName) { if (state.selectedTeamName !== tab.teamName) { // Different team -- full reload (also auto-selects project via selectTeam) void state.selectTeam(tab.teamName); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d33f2a4e..c4e1952d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -3,7 +3,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, - isTaskSummaryCacheableForOptions, + canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; @@ -57,6 +57,43 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } +async function refreshTaskChangePresenceForUpdatedTask( + getState: () => AppState, + teamName: string, + taskId: string +): Promise { + const state = getState(); + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return; + } + + const task = state.selectedTeamData.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + return; + } + + const options = buildTaskChangeRequestOptions(task); + if (!canDisplayTaskChangesForOptions(options)) { + return; + } + + if ( + typeof state.invalidateTaskChangePresence !== 'function' || + typeof state.checkTaskHasChanges !== 'function' + ) { + return; + } + + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + state.invalidateTaskChangePresence([cacheKey]); + + try { + await state.checkTaskHasChanges(teamName, taskId, options); + } catch { + // Best-effort refresh after explicit task transition. + } +} + async function pollProvisioningStatus( getState: () => TeamSlice, runId: string, @@ -92,6 +129,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import type { AppState } from '../types'; import type { AppConfig } from '@renderer/types/data'; import type { + ActiveToolCall, AddMemberRequest, AddTaskCommentRequest, CreateTaskRequest, @@ -104,6 +142,7 @@ import type { MemberSpawnStatusEntry, SendMessageRequest, SendMessageResult, + TaskChangePresenceState, TaskComment, TeamCreateRequest, TeamData, @@ -445,17 +484,54 @@ function collectTaskChangeInvalidationState( }; } -function buildTaskChangeWarmRequests( +function preserveKnownTaskChangePresence( teamName: string, - tasks: TeamData['tasks'] -): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] { - return tasks.flatMap((task) => { - const options = buildTaskChangeRequestOptions(task); - if (!isTaskSummaryCacheableForOptions(options)) { - return []; + prevTasks: TeamData['tasks'] | null | undefined, + nextTasks: TeamData['tasks'] +): TeamData['tasks'] { + if (!Array.isArray(prevTasks) || prevTasks.length === 0 || nextTasks.length === 0) { + return nextTasks; + } + + const prevTaskById = new Map(prevTasks.map((task) => [task.id, task])); + let changed = false; + + const mergedTasks = nextTasks.map((task) => { + if (task.changePresence && task.changePresence !== 'unknown') { + return task; } - return [{ teamName, taskId: task.id, options }]; + + const previousTask = prevTaskById.get(task.id); + if ( + !previousTask || + !previousTask.changePresence || + previousTask.changePresence === 'unknown' + ) { + return task; + } + + const previousKey = buildTaskChangePresenceKey( + teamName, + previousTask.id, + buildTaskChangeRequestOptions(previousTask) + ); + const nextKey = buildTaskChangePresenceKey( + teamName, + task.id, + buildTaskChangeRequestOptions(task) + ); + if (previousKey !== nextKey) { + return task; + } + + changed = true; + return { + ...task, + changePresence: previousTask.changePresence, + }; }); + + return changed ? mergedTasks : nextTasks; } function mapSendMessageError(error: unknown): string { @@ -534,6 +610,8 @@ export interface TeamSlice { currentRuntimeRunIdByTeam: Record; /** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */ ignoredProvisioningRunIds: Record; + /** Runtime runs explicitly tombstoned after stop/offline so late events cannot resurrect UI state. */ + ignoredRuntimeRunIds: Record; /** * Per-team lower bound for provisioning progress timestamps. * Used to ignore late progress events from a previous run after stop→launch. @@ -541,6 +619,9 @@ export interface TeamSlice { provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; leadContextByTeam: Record; + activeToolsByTeam: Record>>; + finishedVisibleByTeam: Record>>; + toolHistoryByTeam: Record>; /** Per-team per-member spawn statuses during team provisioning/launch. */ memberSpawnStatusesByTeam: Record>; fetchMemberSpawnStatuses: (teamName: string) => Promise; @@ -556,6 +637,12 @@ export interface TeamSlice { openTeamsTab: () => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; clearKanbanFilter: () => void; + setSelectedTeamTaskChangePresence: ( + teamName: string, + taskId: string, + presence: TaskChangePresenceState + ) => void; + refreshSelectedTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } @@ -583,6 +670,7 @@ export interface TeamSlice { ) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; updateTaskFields: ( @@ -655,8 +743,13 @@ export interface TeamSlice { subscribeProvisioningProgress: () => void; unsubscribeProvisioningProgress: () => void; pendingApprovals: ToolApprovalRequest[]; + /** Resolved permission approvals: request_id → allowed (true/false). Used for noise row icons. */ + resolvedApprovals: Map; toolApprovalSettings: ToolApprovalSettings; - updateToolApprovalSettings: (patch: Partial) => Promise; + updateToolApprovalSettings: ( + patch: Partial, + forTeam?: string + ) => Promise; respondToToolApproval: ( teamName: string, runId: string, @@ -727,10 +820,11 @@ function extractBaseModel(raw?: string): string | undefined { return raw.replace(/\[1m\]$/, '') || undefined; } -function loadToolApprovalSettings(): ToolApprovalSettings { +const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:'; + +function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings { + if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; try { - const raw = localStorage.getItem('team:toolApprovalSettings'); - if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS; const parsed = JSON.parse(raw) as Record; const d = DEFAULT_TOOL_APPROVAL_SETTINGS; return { @@ -761,6 +855,23 @@ function loadToolApprovalSettings(): ToolApprovalSettings { } } +function loadToolApprovalSettingsForTeam(teamName: string): ToolApprovalSettings { + return parseToolApprovalSettings(localStorage.getItem(TOOL_APPROVAL_PREFIX + teamName)); +} + +function saveToolApprovalSettingsForTeam(teamName: string, settings: ToolApprovalSettings): void { + try { + localStorage.setItem(TOOL_APPROVAL_PREFIX + teamName, JSON.stringify(settings)); + } catch { + // best-effort + } +} + +/** Load global settings (legacy fallback for first load / no team selected). */ +function loadToolApprovalSettings(): ToolApprovalSettings { + return parseToolApprovalSettings(localStorage.getItem('team:toolApprovalSettings')); +} + export const createTeamSlice: StateCreator = (set, get) => ({ teams: [], teamByName: {}, @@ -788,9 +899,13 @@ export const createTeamSlice: StateCreator = (set, currentProvisioningRunIdByTeam: {}, currentRuntimeRunIdByTeam: {}, ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, leadContextByTeam: {}, + activeToolsByTeam: {}, + finishedVisibleByTeam: {}, + toolHistoryByTeam: {}, memberSpawnStatusesByTeam: {}, provisioningErrorByTeam: {}, clearProvisioningError: (teamName?: string) => @@ -813,6 +928,10 @@ export const createTeamSlice: StateCreator = (set, try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); set((prev) => { + if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { + return {}; + } + if ( prev.currentRuntimeRunIdByTeam[teamName] == null && prev.leadActivityByTeam[teamName] === 'offline' && @@ -837,6 +956,14 @@ export const createTeamSlice: StateCreator = (set, ...prev.currentRuntimeRunIdByTeam, [teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId, }, + ignoredRuntimeRunIds: + snapshot.runId == null + ? prev.ignoredRuntimeRunIds + : Object.fromEntries( + Object.entries(prev.ignoredRuntimeRunIds).filter( + ([, ignoredTeamName]) => ignoredTeamName !== teamName + ) + ), memberSpawnStatusesByTeam: { ...prev.memberSpawnStatusesByTeam, [teamName]: snapshot.statuses, @@ -864,6 +991,7 @@ export const createTeamSlice: StateCreator = (set, deletedTasks: [], deletedTasksLoading: false, pendingApprovals: [], + resolvedApprovals: new Map(), toolApprovalSettings: loadToolApprovalSettings(), // Messages panel UI state @@ -873,17 +1001,31 @@ export const createTeamSlice: StateCreator = (set, setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), fetchBranches: async (paths: string[]) => { - const results: Record = {}; - for (const p of paths) { - try { - const branch = await api.teams.getProjectBranch(p); - results[normalizePath(p)] = branch; - } catch { - results[normalizePath(p)] = null; - } - } + const entries = await Promise.all( + paths.map(async (p) => { + try { + const branch = await api.teams.getProjectBranch(p); + return [normalizePath(p), branch] as const; + } catch { + return [normalizePath(p), null] as const; + } + }) + ); + const results: Record = Object.fromEntries(entries); if (Object.keys(results).length > 0) { - set((state) => ({ branchByPath: { ...state.branchByPath, ...results } })); + set((state) => { + let changed = false; + for (const [key, value] of Object.entries(results)) { + if (state.branchByPath[key] !== value) { + changed = true; + break; + } + } + if (!changed) { + return {}; + } + return { branchByPath: { ...state.branchByPath, ...results } }; + }); } }, @@ -1098,6 +1240,89 @@ export const createTeamSlice: StateCreator = (set, set({ kanbanFilterQuery: null }); }, + setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { + set((state) => { + let selectedChanged = false; + const nextSelectedTeamData = + state.selectedTeamName === teamName && state.selectedTeamData + ? { + ...state.selectedTeamData, + tasks: state.selectedTeamData.tasks.map((task) => { + if (task.id !== taskId || task.changePresence === presence) { + return task; + } + selectedChanged = true; + return { ...task, changePresence: presence }; + }), + } + : state.selectedTeamData; + + let globalChanged = false; + const nextGlobalTasks = state.globalTasks.map((task) => { + if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) { + return task; + } + globalChanged = true; + return { ...task, changePresence: presence }; + }); + + if (!selectedChanged && !globalChanged) { + return {}; + } + + return { + ...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), + ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), + }; + }); + }, + + refreshSelectedTeamChangePresence: async (teamName: string) => { + const selected = get().selectedTeamData; + if (get().selectedTeamName !== teamName || !selected) { + return; + } + + try { + const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () => + api.teams.getTaskChangePresence(teamName) + ); + + if (get().selectedTeamName !== teamName || !get().selectedTeamData) { + return; + } + + set((state) => { + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return {}; + } + + let changed = false; + const nextTasks = state.selectedTeamData.tasks.map((task) => { + const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; + if (task.changePresence === nextPresence) { + return task; + } + changed = true; + return { ...task, changePresence: nextPresence }; + }); + + if (!changed) { + return {}; + } + + return { + selectedTeamData: { + ...state.selectedTeamData, + tasks: nextTasks, + }, + }; + }); + } catch { + // best-effort lightweight refresh; keep current UI state on failure + } + }, + selectTeam: async (teamName: string, opts) => { const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. @@ -1122,6 +1347,8 @@ export const createTeamSlice: StateCreator = (set, selectedTeamLoadNonce: requestNonce, selectedTeamError: null, reviewActionError: null, + // Load per-team tool approval settings + toolApprovalSettings: loadToolApprovalSettingsForTeam(teamName), }); try { @@ -1156,7 +1383,12 @@ export const createTeamSlice: StateCreator = (set, set({ selectedTeamName: teamName, - selectedTeamData: data, + selectedTeamData: previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data, selectedTeamLoading: false, selectedTeamError: null, }); @@ -1169,11 +1401,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } - // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -1282,7 +1509,12 @@ export const createTeamSlice: StateCreator = (set, return; } set({ - selectedTeamData: data, + selectedTeamData: previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data, selectedTeamError: null, }); const invalidationState = previousData @@ -1294,10 +1526,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } } catch (error) { if (get().selectedTeamName !== teamName) { return; @@ -1423,6 +1651,7 @@ export const createTeamSlice: StateCreator = (set, set({ reviewActionError: null }); await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); } catch (error) { set({ reviewActionError: mapReviewError(error), @@ -1440,6 +1669,16 @@ export const createTeamSlice: StateCreator = (set, startTask: async (teamName: string, taskId: string) => { const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); + return result; + }, + + startTaskByUser: async (teamName: string, taskId: string) => { + const result = await unwrapIpc('team:startTaskByUser', () => + api.teams.startTaskByUser(teamName, taskId) + ); + await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); return result; }, @@ -1448,6 +1687,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.updateTaskStatus(teamName, taskId, status) ); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); }, updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => { @@ -1616,19 +1856,44 @@ export const createTeamSlice: StateCreator = (set, delete nextErrors[request.teamName]; const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; delete nextSpawnStatuses[request.teamName]; + const nextActiveTools = { ...state.activeToolsByTeam }; + delete nextActiveTools[request.teamName]; + const nextFinishedVisible = { ...state.finishedVisibleByTeam }; + delete nextFinishedVisible[request.teamName]; + const nextToolHistory = { ...state.toolHistoryByTeam }; + delete nextToolHistory[request.teamName]; const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; + const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; const nextIgnoredRunIds = Object.fromEntries( Object.entries(state.ignoredProvisioningRunIds).filter( ([, teamName]) => teamName !== request.teamName ) ); + const nextIgnoredRuntimeRunIds = previousRuntimeRunId + ? { + ...Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ), + [previousRuntimeRunId]: request.teamName, + } + : Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, + activeToolsByTeam: nextActiveTools, + finishedVisibleByTeam: nextFinishedVisible, + toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, }; }); @@ -1666,6 +1931,13 @@ export const createTeamSlice: StateCreator = (set, }, }, })); + // Initialize per-team tool approval settings based on skipPermissions flag + const initialSettings: ToolApprovalSettings = + request.skipPermissions === false + ? DEFAULT_TOOL_APPROVAL_SETTINGS + : { ...DEFAULT_TOOL_APPROVAL_SETTINGS, autoAllowAll: true }; + saveToolApprovalSettingsForTeam(request.teamName, initialSettings); + set({ toolApprovalSettings: initialSettings }); try { if (typeof api.teams.createTeam !== 'function') { throw new Error( @@ -1712,6 +1984,11 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, + ignoredRuntimeRunIds: Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ), }; }); try { @@ -1773,19 +2050,44 @@ export const createTeamSlice: StateCreator = (set, delete nextErrors[request.teamName]; const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; delete nextSpawnStatuses[request.teamName]; + const nextActiveTools = { ...state.activeToolsByTeam }; + delete nextActiveTools[request.teamName]; + const nextFinishedVisible = { ...state.finishedVisibleByTeam }; + delete nextFinishedVisible[request.teamName]; + const nextToolHistory = { ...state.toolHistoryByTeam }; + delete nextToolHistory[request.teamName]; const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam }; + const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName]; delete nextRuntimeRunIdByTeam[request.teamName]; const nextIgnoredRunIds = Object.fromEntries( Object.entries(state.ignoredProvisioningRunIds).filter( ([, teamName]) => teamName !== request.teamName ) ); + const nextIgnoredRuntimeRunIds = previousRuntimeRunId + ? { + ...Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ), + [previousRuntimeRunId]: request.teamName, + } + : Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ); return { provisioningRuns: cleaned, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, + activeToolsByTeam: nextActiveTools, + finishedVisibleByTeam: nextFinishedVisible, + toolHistoryByTeam: nextToolHistory, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, }; }); @@ -1808,6 +2110,15 @@ export const createTeamSlice: StateCreator = (set, [request.teamName]: pendingRunId, }, })); + // Initialize per-team tool approval settings based on skipPermissions flag + { + const launchSettings: ToolApprovalSettings = + request.skipPermissions === false + ? DEFAULT_TOOL_APPROVAL_SETTINGS + : { ...DEFAULT_TOOL_APPROVAL_SETTINGS, autoAllowAll: true }; + saveToolApprovalSettingsForTeam(request.teamName, launchSettings); + set({ toolApprovalSettings: launchSettings }); + } try { const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); @@ -1849,6 +2160,11 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [request.teamName]: response.runId, }, + ignoredRuntimeRunIds: Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== request.teamName + ) + ), }; }); try { @@ -1916,18 +2232,37 @@ export const createTeamSlice: StateCreator = (set, ...state.ignoredProvisioningRunIds, [runId]: existing.teamName, }; + const nextIgnoredRuntimeRunIds = + state.currentRuntimeRunIdByTeam[existing.teamName] === runId + ? { + ...state.ignoredRuntimeRunIds, + [runId]: existing.teamName, + } + : state.ignoredRuntimeRunIds; const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; if (isCanonicalRun) { delete nextSpawnStatuses[existing.teamName]; } + const nextActiveTools = { ...state.activeToolsByTeam }; + const nextFinishedVisible = { ...state.finishedVisibleByTeam }; + const nextToolHistory = { ...state.toolHistoryByTeam }; + if (isCanonicalRun) { + delete nextActiveTools[existing.teamName]; + delete nextFinishedVisible[existing.teamName]; + delete nextToolHistory[existing.teamName]; + } return { provisioningRuns: nextRuns, currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, memberSpawnStatusesByTeam: nextSpawnStatuses, + activeToolsByTeam: nextActiveTools, + finishedVisibleByTeam: nextFinishedVisible, + toolHistoryByTeam: nextToolHistory, ignoredProvisioningRunIds: nextIgnoredRunIds, + ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds, }; }); }, @@ -1940,6 +2275,9 @@ export const createTeamSlice: StateCreator = (set, if (get().ignoredProvisioningRunIds[progress.runId] === progress.teamName) { return; } + if (get().ignoredRuntimeRunIds[progress.runId] === progress.teamName) { + return; + } const floor = get().provisioningStartedAtFloorByTeam[progress.teamName]; if (floor && progress.startedAt < floor) { @@ -2019,6 +2357,11 @@ export const createTeamSlice: StateCreator = (set, ...state.currentRuntimeRunIdByTeam, [progress.teamName]: progress.runId, }, + ignoredRuntimeRunIds: Object.fromEntries( + Object.entries(state.ignoredRuntimeRunIds).filter( + ([, teamName]) => teamName !== progress.teamName + ) + ), provisioningErrorByTeam: nextErrors, provisioningSnapshotByTeam: nextSnapshots, }; @@ -2067,13 +2410,19 @@ export const createTeamSlice: StateCreator = (set, set({ provisioningProgressUnsubscribe: unsubscribe }); }, - updateToolApprovalSettings: async (patch) => { + updateToolApprovalSettings: async (patch, forTeam) => { + const teamName = forTeam ?? get().selectedTeamName; const current = get().toolApprovalSettings; const merged = { ...current, ...patch }; set({ toolApprovalSettings: merged }); - localStorage.setItem('team:toolApprovalSettings', JSON.stringify(merged)); + // Save per-team if a team is selected, otherwise global fallback + if (teamName) { + saveToolApprovalSettingsForTeam(teamName, merged); + } else { + localStorage.setItem('team:toolApprovalSettings', JSON.stringify(merged)); + } try { - await api.teams.updateToolApprovalSettings(merged); + await api.teams.updateToolApprovalSettings(teamName ?? '__global__', merged); } catch (err) { logger.warn('Failed to sync tool approval settings to main:', err); } @@ -2083,11 +2432,16 @@ export const createTeamSlice: StateCreator = (set, try { await api.teams.respondToToolApproval(teamName, runId, requestId, allow, message); // Remove ONLY after successful IPC, by runId+requestId pair - set((s) => ({ - pendingApprovals: s.pendingApprovals.filter( - (a) => !(a.runId === runId && a.requestId === requestId) - ), - })); + set((s) => { + const next = new Map(s.resolvedApprovals); + next.set(requestId, allow); + return { + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.runId === runId && a.requestId === requestId) + ), + resolvedApprovals: next, + }; + }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.error(`respondToToolApproval failed for ${teamName}/${requestId}: ${msg}`); diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index fd4bdca0..a708abc8 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -5,10 +5,12 @@ export interface MentionSuggestion { name: string; /** Role displayed in suggestion list */ subtitle?: string; + /** Optional description for command and rich suggestion tooltips */ + description?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */ - type?: 'member' | 'team' | 'file' | 'folder' | 'task'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ @@ -19,6 +21,8 @@ export interface MentionSuggestion { insertText?: string; /** Optional extra searchable text (subject, team name, path, etc.) */ searchText?: string; + /** Optional slash command string including leading slash (command suggestions only) */ + command?: `/${string}`; /** Canonical task id (task suggestions only) */ taskId?: string; /** Owning team name (task suggestions only) */ diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 65ffe18c..f8ebc7a2 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -85,7 +85,8 @@ export interface Tab { | 'team' | 'report' | 'extensions' - | 'schedules'; + | 'schedules' + | 'graph'; /** Session ID (required when type === 'session') */ sessionId?: string; diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index f194bc96..354f1513 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -73,6 +73,8 @@ const TYPE_LABELS: Record = { shutdown_response: 'Shutdown response', message: 'Message', broadcast: 'Broadcast', + permission_request: 'Permission request', + permission_response: 'Permission response', }; export function parseStructuredAgentMessage(content: string): StructuredAgentMessage | null { diff --git a/src/renderer/utils/markdownPlugins.ts b/src/renderer/utils/markdownPlugins.ts index 1652702b..0fccb94c 100644 --- a/src/renderer/utils/markdownPlugins.ts +++ b/src/renderer/utils/markdownPlugins.ts @@ -39,6 +39,11 @@ const sanitizeSchema: SanitizeSchema = { // Allow title on abbr (for tooltip definitions) abbr: [...(defaultSchema.attributes?.abbr ?? []), 'title'], }, + protocols: { + ...defaultSchema.protocols, + // Allow internal-only protocols used for mention badges, team badges, and task tooltips + href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'team', 'task'], + }, }; /** Full plugin chain: raw HTML → sanitize → syntax highlighting */ diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index c8c37d4f..a83e1bed 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -1,10 +1,15 @@ import type { MentionSuggestion } from '@renderer/types/mention'; -export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' { - return suggestion.type === 'task' ? '#' : '@'; +export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { + if (suggestion.type === 'task') return '#'; + if (suggestion.type === 'command') return '/'; + return '@'; } export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { + if (suggestion.type === 'command') { + return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name; + } return suggestion.insertText ?? suggestion.name; } @@ -15,10 +20,12 @@ export function doesSuggestionMatchQuery(suggestion: MentionSuggestion, query: s const haystacks = [ suggestion.name, suggestion.subtitle, + suggestion.description, suggestion.relativePath, suggestion.searchText, suggestion.teamDisplayName, suggestion.teamName, + suggestion.command, ] .filter(Boolean) .map((value) => value!.toLowerCase()); diff --git a/src/renderer/utils/messageRenderEquality.ts b/src/renderer/utils/messageRenderEquality.ts index f9da51ad..ef25acb2 100644 --- a/src/renderer/utils/messageRenderEquality.ts +++ b/src/renderer/utils/messageRenderEquality.ts @@ -89,6 +89,29 @@ export function areToolCallsEqual( return true; } +export function areSlashCommandsEqual( + prev?: InboxMessage['slashCommand'], + next?: InboxMessage['slashCommand'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return ( + prev.name === next.name && + prev.command === next.command && + prev.args === next.args && + prev.knownDescription === next.knownDescription + ); +} + +export function areCommandOutputsEqual( + prev?: InboxMessage['commandOutput'], + next?: InboxMessage['commandOutput'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return prev.stream === next.stream && prev.commandLabel === next.commandLabel; +} + export function areInboxMessagesEquivalentForRender( prev: InboxMessage, next: InboxMessage @@ -107,10 +130,13 @@ export function areInboxMessagesEquivalentForRender( if (prev.source !== next.source) return false; if (prev.leadSessionId !== next.leadSessionId) return false; if (prev.toolSummary !== next.toolSummary) return false; + if (prev.messageKind !== next.messageKind) return false; return ( areTaskRefsEqual(prev.taskRefs, next.taskRefs) && - areAttachmentsEqual(prev.attachments, next.attachments) + areAttachmentsEqual(prev.attachments, next.attachments) && + areSlashCommandsEqual(prev.slashCommand, next.slashCommand) && + areCommandOutputsEqual(prev.commandOutput, next.commandOutput) ); } diff --git a/src/renderer/utils/sessionTitleParser.ts b/src/renderer/utils/sessionTitleParser.ts new file mode 100644 index 00000000..45a80946 --- /dev/null +++ b/src/renderer/utils/sessionTitleParser.ts @@ -0,0 +1,69 @@ +/** + * Parses session `firstMessage` into a structured title for sidebar display. + * + * Source formats (generated in src/main/services/team/TeamProvisioningService.ts): + * New team (line ~944): agent_teams_ui [Agent Team: "name" | Project: "proj" | Lead: "lead"] ... + * Resume (line ~1046): Team Start [Agent Team: "name" | Project: "proj" | Lead: "lead"] ... + * (line ~1044): Team Start (resume) [Agent Team: ...] ... + */ + +export interface ParsedSessionTitle { + kind: 'team-new' | 'team-resume' | 'regular'; + /** Cleaned display text — team name for team sessions, cleaned prompt for regular */ + displayText: string; + teamName?: string; + projectName?: string; +} + +// Matches: agent_teams_ui [Agent Team: "name" | Project: "proj" | Lead: "lead"] +// Handles both straight quotes ("") and smart quotes (\u201C\u201D) +const PROVISION_RE = + /^agent_teams_ui\s+\[Agent Team:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Project:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Lead:\s*["\u201C]([^"\u201D]+)["\u201D]\]/; + +// Matches: Team Start [Agent Team: ...] (after stripping optional "(resume)" prefix) +const LAUNCH_RE = + /^Team Start\s+\[Agent Team:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Project:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Lead:\s*["\u201C]([^"\u201D]+)["\u201D]\]/; + +// Matches one or more [Image #N] prefixes +const IMAGE_PREFIX_RE = /^(?:\[Image\s+#\d+\]\s*)+/; + +export function parseSessionTitle(firstMessage: string | undefined): ParsedSessionTitle { + if (!firstMessage) { + return { kind: 'regular', displayText: 'Untitled' }; + } + + // New team provisioning: agent_teams_ui [Agent Team: ...] + const provisionMatch = PROVISION_RE.exec(firstMessage); + if (provisionMatch) { + return { + kind: 'team-new', + displayText: provisionMatch[1], + teamName: provisionMatch[1], + projectName: provisionMatch[2], + }; + } + + // Team resume/launch: Team Start [Agent Team: ...] or Team Start (resume) [...] + const launchMsg = firstMessage.replace(/^(Team Start)\s*\(resume\)/, '$1'); + const launchMatch = LAUNCH_RE.exec(launchMsg); + if (launchMatch) { + return { + kind: 'team-resume', + displayText: launchMatch[1], + teamName: launchMatch[1], + projectName: launchMatch[2], + }; + } + + // Regular session — strip [Image #N] prefixes + const cleaned = firstMessage.replace(IMAGE_PREFIX_RE, '').trim(); + return { + kind: 'regular', + displayText: cleaned || 'Untitled', + }; +} + +/** Convenience: returns just the display label string. */ +export function formatSessionLabel(firstMessage: string | undefined): string { + return parseSessionTitle(firstMessage).displayText; +} diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index e384deca..64960d9c 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -3,12 +3,11 @@ import { isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; +import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; -const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; - export type TaskChangeRequestOptions = NonNullable[2]>; export interface TaskChangeContext { @@ -31,27 +30,7 @@ type TaskChangeTaskLike = Pick< >; export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined { - if (!task) return undefined; - - const sources: string[] = []; - if (task.createdAt) sources.push(task.createdAt); - if (Array.isArray(task.workIntervals)) { - for (const interval of task.workIntervals) { - if (interval.startedAt) sources.push(interval.startedAt); - } - } - if (Array.isArray(task.historyEvents)) { - for (const event of task.historyEvents) { - if (event.timestamp) sources.push(event.timestamp); - } - } - if (sources.length === 0) return undefined; - - const [first, ...rest] = sources; - const earliest = rest.reduce((a, b) => (a < b ? a : b), first); - const date = new Date(earliest); - date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); - return date.toISOString(); + return deriveSharedTaskSince(task); } export function buildTaskChangeRequestOptions( diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 4eac6397..c5925605 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -18,7 +18,7 @@ export function filterTeamMessages( ): InboxMessage[] { const { timeWindow, filter, searchQuery } = options; - let list = messages; + let list = messages.filter((m) => m.messageKind !== 'task_comment_notification'); if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 827db198..132b9c26 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + declare module '*.png' { const src: string; // eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts index 3d596c39..09aa655e 100644 --- a/src/shared/constants/agentBlocks.ts +++ b/src/shared/constants/agentBlocks.ts @@ -81,6 +81,16 @@ export function extractAgentBlockContents(text: string): string[] { */ export const AGENT_BLOCK_REGEX = new RegExp(AGENT_BLOCK_PATTERN, 'g'); +/** + * Wraps text in agent-only block markers. + * Use this instead of manually concatenating AGENT_BLOCK_OPEN/CLOSE. + */ +export function wrapAgentBlock(text: string): string { + const trimmed = text.trim(); + if (trimmed.length === 0) return ''; + return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; +} + /** * Fenced code block marker for reply messages between agents. * diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8451ab8b..e52ab6ea 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -51,10 +51,12 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + ProjectBranchChangeEvent, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TeamChangeEvent, TeamClaudeLogsQuery, @@ -416,6 +418,9 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; getData: (teamName: string) => Promise; + getTaskChangePresence: (teamName: string) => Promise>; + setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; + setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; @@ -443,6 +448,7 @@ export interface TeamsAPI { fields: { subject?: string; description?: string } ) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; aliveList: () => Promise; @@ -484,6 +490,7 @@ export interface TeamsAPI { value: 'lead' | 'user' | null ) => Promise; getProjectBranch: (projectPath: string) => Promise; + setProjectBranchTracking: (projectPath: string, enabled: boolean) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; @@ -525,6 +532,9 @@ export interface TeamsAPI { attachmentId: string, mimeType: string ) => Promise; + onProjectBranchChange: ( + callback: (event: unknown, data: ProjectBranchChangeEvent) => void + ) => () => void; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void @@ -538,7 +548,7 @@ export interface TeamsAPI { ) => Promise; validateCliArgs: (rawArgs: string) => Promise; onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void; - updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise; + updateToolApprovalSettings: (teamName: string, settings: ToolApprovalSettings) => Promise; readFileForToolApproval: (filePath: string) => Promise; } diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 91b4bc68..9149ead1 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -83,6 +83,8 @@ export interface CliInstallerAPI { getStatus: () => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; + /** Invalidate cached status (forces fresh check on next getStatus) */ + invalidateStatus: () => Promise; /** Subscribe to progress events. Returns cleanup function. */ onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f87cba50..b8f4e817 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -166,6 +166,24 @@ export interface SourceMessageSnapshot { }[]; } +export type InboxMessageKind = + | 'default' + | 'slash_command' + | 'slash_command_result' + | 'task_comment_notification'; + +export interface SlashCommandMeta { + name: string; + command: `/${string}`; + args?: string; + knownDescription?: string; +} + +export interface CommandOutputMeta { + stream: 'stdout' | 'stderr'; + commandLabel: string; +} + // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. // Adding a field here without mapping it there will cause a compile error. export interface TeamTask { @@ -218,11 +236,15 @@ export interface TeamTask { } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ +export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown'; + export interface TeamTaskWithKanban extends TeamTask { /** Set when task is in team kanban (review or approved column). */ kanbanColumn?: 'review' | 'approved'; /** Reviewer assigned in kanban state, when applicable. */ reviewer?: string | null; + /** Cheap persisted change-presence state for kanban rendering. */ + changePresence?: TaskChangePresenceState; } /** Metadata for an attachment associated with a task or comment. */ @@ -286,6 +308,43 @@ export interface ToolCallMeta { name: string; /** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */ preview?: string; + /** Optional runtime tool_use identifier when available. */ + toolUseId?: string; +} + +export type ToolActivitySource = 'runtime' | 'member_log' | 'inbox'; +export type ToolActivityState = 'running' | 'complete' | 'error'; + +/** Live or recently finished tool activity for one team member. */ +export interface ActiveToolCall { + memberName: string; + toolUseId: string; + toolName: string; + preview?: string; + startedAt: string; + state: ToolActivityState; + source: ToolActivitySource; + finishedAt?: string; + resultPreview?: string; +} + +/** Renderer-facing event payload for tool lifecycle updates. */ +export interface ToolActivityEventPayload { + action: 'start' | 'finish' | 'reset'; + activity?: { + memberName: string; + toolUseId: string; + toolName: string; + preview?: string; + startedAt: string; + source: ToolActivitySource; + }; + memberName?: string; + toolUseId?: string; + toolUseIds?: string[]; + finishedAt?: string; + resultPreview?: string; + isError?: boolean; } export interface InboxMessage { @@ -319,6 +378,12 @@ export interface InboxMessage { toolSummary?: string; /** Structured tool call details for tooltip display. */ toolCalls?: ToolCallMeta[]; + /** Renderer-friendly semantic kind. Defaults to "default" when absent. */ + messageKind?: InboxMessageKind; + /** Structured slash-command metadata for sent command rows. */ + slashCommand?: SlashCommandMeta; + /** Structured command-output metadata for session-derived result rows. */ + commandOutput?: CommandOutputMeta; } export type AgentActionMode = 'do' | 'ask' | 'delegate'; @@ -344,6 +409,9 @@ export interface SendMessageRequest { replyToConversationId?: string; toolSummary?: string; toolCalls?: ToolCallMeta[]; + messageKind?: InboxMessageKind; + slashCommand?: SlashCommandMeta; + commandOutput?: CommandOutputMeta; } export interface SendMessageResult { @@ -502,10 +570,12 @@ export interface TeamChangeEvent { type: | 'config' | 'inbox' + | 'log-source-change' | 'task' | 'lead-activity' | 'lead-context' | 'lead-message' + | 'tool-activity' | 'process' | 'member-spawn'; teamName: string; @@ -513,6 +583,11 @@ export interface TeamChangeEvent { detail?: string; } +export interface ProjectBranchChangeEvent { + projectPath: string; + branch: string | null; +} + /** Per-member spawn status entry, exposed to renderer via IPC. */ export interface MemberSpawnStatusEntry { status: MemberSpawnStatus; @@ -813,6 +888,15 @@ export interface ToolApprovalRequest { teamColor?: string; /** Team display name (from config or create request). */ teamDisplayName?: string; + /** Permission suggestions from teammate runtime (only for teammate permission_request). + * FACT: Populated by Claude Code runtime, contains instructions to add permission rules. + */ + permissionSuggestions?: { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; + }[]; } /** Dismissal event — process died, all pending approvals for this team+run should be removed. */ diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index e85c145d..06430637 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -21,18 +21,29 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/system-reminder>/gi, ]; +export interface CommandOutputInfo { + stream: 'stdout' | 'stderr'; + output: string; +} + /** * Extract content from tags. * Returns the command output without the wrapper tags. */ -function extractCommandOutput(content: string): string | null { +export function extractCommandOutputInfo(content: string): CommandOutputInfo | null { const match = /([\s\S]*?)<\/local-command-stdout>/i.exec(content); const matchStderr = /([\s\S]*?)<\/local-command-stderr>/i.exec(content); if (match) { - return match[1].trim(); + return { + stream: 'stdout', + output: match[1].trim(), + }; } if (matchStderr) { - return matchStderr[1].trim(); + return { + stream: 'stderr', + output: matchStderr[1].trim(), + }; } return null; } @@ -84,9 +95,9 @@ export function isCommandOutputContent(content: string): boolean { export function sanitizeDisplayContent(content: string): string { // If it's a command output message, extract the output content if (isCommandOutputContent(content)) { - const commandOutput = extractCommandOutput(content); + const commandOutput = extractCommandOutputInfo(content); if (commandOutput) { - return commandOutput; + return commandOutput.output; } } diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index d390076c..0886e309 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -37,6 +37,65 @@ export function isInboxNoiseMessage(text: string): boolean { return !!type && INBOX_NOISE_SET.has(type); } +// --------------------------------------------------------------------------- +// Teammate permission request parsing +// --------------------------------------------------------------------------- + +/** A single permission suggestion from the teammate runtime. */ +export interface PermissionSuggestion { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; + /** Permission mode name (for type: "setMode"). FACT: observed values: "acceptEdits", "bypassPermissions" */ + mode?: string; +} + +/** Parsed teammate permission request from inbox message. */ +export interface ParsedPermissionRequest { + requestId: string; + agentId: string; + toolName: string; + toolUseId: string; + description: string; + input: Record; + /** Suggestions from teammate runtime on how to resolve the permission. + * FACT: This field is populated by Claude Code runtime, not by the AI agent. + * FACT: Observed format: { type: "addRules", rules: [{toolName}], behavior: "allow", destination: "localSettings" } + */ + permissionSuggestions: PermissionSuggestion[]; +} + +/** + * Parses a `permission_request` JSON message from a teammate's inbox entry. + * Returns null if the text is not a valid permission_request. + */ +export function parsePermissionRequest(text: string): ParsedPermissionRequest | null { + const parsed = parseInboxJson(text); + if (!parsed || parsed.type !== 'permission_request') return null; + + const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null; + const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null; + const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : null; + + if (!requestId || !agentId || !toolName) return null; + + return { + requestId, + agentId, + toolName, + toolUseId: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + description: typeof parsed.description === 'string' ? parsed.description : '', + input: + parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) + ? (parsed.input as Record) + : {}, + permissionSuggestions: Array.isArray(parsed.permission_suggestions) + ? (parsed.permission_suggestions as PermissionSuggestion[]) + : [], + }; +} + // --------------------------------------------------------------------------- // Teammate-message XML block detection & stripping // --------------------------------------------------------------------------- @@ -67,14 +126,23 @@ export function isOnlyTeammateMessageBlocks(text: string): boolean { // Combined protocol noise check for lead thoughts // --------------------------------------------------------------------------- +/** + * Detects `` opening tags (even without closing tag). + * Claude's lead model sometimes echoes raw teammate message XML in assistant + * text output — these are always protocol artifacts, never real user content. + */ +const TEAMMATE_MESSAGE_OPEN_RE = /^\s*