diff --git a/docs/team-management/README.md b/docs/team-management/README.md index c5938ee2..d1f21d8e 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -1,135 +1,135 @@ # Team Management Feature -Интерфейс для управления командами AI-тиммейтов внутри Agent Teams (Electron), включая Claude, Codex и OpenCode runtime paths. +UI for managing AI teammate teams inside Agent Teams (Electron), including Claude, Codex, and OpenCode runtime paths. -## Что делает +## What It Does -- Видеть состав команды и роли участников -- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED -- Отправка сообщений тиммейтам через inbox-файлы и runtime-aware delivery для OpenCode -- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE -- Live updates через file watcher +- Shows team members and their roles. +- Provides a Kanban board with 5 columns: TODO, IN PROGRESS, REVIEW, DONE, APPROVED. +- Sends messages to teammates through inbox files and runtime-aware delivery for OpenCode. +- Supports review flow: review requests, manual review, and direct manual approval from DONE. +- Provides live updates through the file watcher. -## Документация +## Documentation -| Файл | Содержание | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений | -| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ | -| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox | -| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json | -| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification | -| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API | -| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) | -| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync | -| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug | -| [adaptive-task-graphs-research-note.md](./adaptive-task-graphs-research-note.md) | Research note по LATTE/AgentConductor: dynamic task graphs, frontier scheduling, selective verify, release stragglers | +| File | Contents | +| ---- | -------- | +| [research-inbox.md](./research-inbox.md) | Inbox file format, race conditions, atomic writes, message delivery | +| [research-tasks.md](./research-tasks.md) | Task file format, .lock, .highwatermark, concurrent access | +| [research-messaging.md](./research-messaging.md) | Comparison of inbox, SDK, and CLI approaches, and why inbox was chosen | +| [kanban-design.md](./kanban-design.md) | Kanban flow, columns, review mechanism, kanban-state.json | +| [implementation.md](./implementation.md) | Technical plan: files, steps, verification | +| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API | +| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, launching Claude processes from the UI (Phase 2) | +| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Detailed rollout plan for queue/inventory split, derived actionOwner, and phased agenda/delta sync | +| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, including `CLAUDE_TEAM_TEAMMATE_MODE=tmux` for pane-backed teammate debug | +| [adaptive-task-graphs-research-note.md](./adaptive-task-graphs-research-note.md) | Research note on LATTE/AgentConductor: dynamic task graphs, frontier scheduling, selective verify, release stragglers | -## Ключевые решения +## Key Decisions -⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md). +Warning: `docs/iterations/*` contains historical planning notes. These files are useful for context, but they are not the source of truth for current product behavior. The current review-flow contract is documented here and in [kanban-design.md](./kanban-design.md). -⚠️ `agent-attachments-*.md` (architecture plan + phase 1-5 plans) - это исторические дизайн-документы для feature attachments. Фактическая реализация в `src/features/agent-attachments/` может отличаться от описанной архитектуры. Для актуального состояния см. код в `src/features/agent-attachments/core/domain/` и тесты. +Warning: `agent-attachments-*.md` files (architecture plan + phase 1-5 plans) are historical design documents for feature attachments. The actual implementation in `src/features/agent-attachments/` may differ from that architecture. For current behavior, see the code in `src/features/agent-attachments/core/domain/` and the tests. -### 1. Messaging: inbox + runtime delivery +### 1. Messaging: Inbox + Runtime Delivery -Для native Claude/Codex-style тиммейтов основной путь - durable inbox-файлы. Lead inbox доставляется через `relayLeadInboxMessages()`, потому что lead читает stdin. OpenCode secondary lanes не читают `inboxes/{member}.json` напрямую, поэтому UI сначала сохраняет сообщение в inbox, затем доставляет его через runtime bridge с delivery proof. Подробности: [research-messaging.md](./research-messaging.md) и [debugging-agent-teams.md](./debugging-agent-teams.md) +For native Claude/Codex-style teammates, the primary path is durable inbox files. Lead inbox delivery uses `relayLeadInboxMessages()` because the lead reads stdin. OpenCode secondary lanes do not read `inboxes/{member}.json` directly, so the UI first persists the message to the inbox and then delivers it through the runtime bridge with delivery proof. Details: [research-messaging.md](./research-messaging.md) and [debugging-agent-teams.md](./debugging-agent-teams.md). -### 1.1 Roster source: members.meta.json + inboxes +### 1.1 Roster Source: members.meta.json + inboxes -- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI). -- Источник метаданных участников (role/color/agentType): `members.meta.json`. -- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`. +- `config.json` is not used as the complete member registry. It may contain only the team lead and CLI service fields. +- Member metadata source (role/color/agentType): `members.meta.json`. +- Runtime membership and message-addressing source: `inboxes/{member}.json`. -### 2. Kanban Storage: Собственный файл +### 2. Kanban Storage: Dedicated File -Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md) +Kanban position (REVIEW, APPROVED) is stored in `kanban-state.json`, not task metadata. Reason: task metadata may be overwritten by an agent during TaskUpdate. Details: [kanban-design.md](./kanban-design.md). ### 3. Review Flow: Approve / Request Changes -- Есть ревьюверы в команде → автоматическое назначение через inbox -- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW` -- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI) -- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix` +- Reviewers exist in the team -> automatic assignment through inbox. +- The user can also manually approve a task directly from `DONE` without entering `REVIEW`. +- No reviewers -> manual user review (Approve / Request Changes in the UI). +- Request Changes -> the user optionally describes the issue -> the task returns to its owner in `pending` with `needsFix`. ### 4. Atomic Write -Все записи через tmp + rename для предотвращения corrupted JSON. +All writes use tmp + rename to prevent corrupted JSON. ### 5. Sender Identity -Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает. +Messages are sent with `from: "user"`. Fallback to `from: "team-lead"` exists only if needed. -## Финальные решения после ревью +## Final Decisions After Review -По итогам 3 раундов ревью (13 экспертов) приняты следующие решения: +After 3 review rounds with 13 experts, the following decisions were accepted. -### Inbox: Atomic write + messageId verify +### Inbox: Atomic Write + messageId Verify -- Atomic write (tmp + rename) предотвращает corrupted JSON -- После записи читаем файл обратно и проверяем наличие нашего `messageId` -- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна -- Риск race condition с агентом реален, но вероятность низкая +- Atomic write (tmp + rename) prevents corrupted JSON. +- After writing, read the file back and verify that our `messageId` is present. +- A full CAS/retry loop is not needed for MVP. Verification on the next read is enough. +- Race condition risk with an agent is real, but probability is low. -### Kanban: kanban-state.json с безопасным GC +### Kanban: kanban-state.json With Safe GC -- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks -- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан +- Stale `kanban-state` entries are garbage-collected only after all tasks are fully loaded. +- Otherwise, startup can race: GC may delete an entry before the task file has been read. ### Review Flow: Approve / Request Changes -- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error) -- Комментарий при Request Changes — опционален -- Manual UI допускает два valid path: +- Buttons were renamed: **Approve** instead of OK, and **Request Changes** instead of Error. +- Request Changes comment is optional. +- Manual UI allows two valid paths: - `DONE -> REVIEW -> APPROVED` - - `DONE -> APPROVED` как быстрый manual approval -- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix` -- `reviewHistory` и round-robin балансировка → Phase 2, не MVP + - `DONE -> APPROVED` as fast manual approval +- `Request Changes` removes the kanban-state entry and returns the task to `pending` with `needsFix`. +- `reviewHistory` and round-robin balancing are Phase 2, not MVP. -### Members: полный список через union +### Members: Complete List Through Union -- `union(members.meta.json + config members + inbox filenames + task owners)` - единственный способ получить полный список -- `owner` в task-файлах — опционален (агент может не иметь owner до назначения) +- `union(members.meta.json + config members + inbox filenames + task owners)` is the only way to get the complete member list. +- `owner` in task files is optional. An agent may not have an owner before assignment. ### Graceful Degradation -- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты -- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED` - - `ACTIVE`: idle < 5 минут - - `IDLE`: idle > 5 минут - - `TERMINATED`: получен `shutdown_response` с `approve: true` +- `try/catch` is used throughout `TeamDataService`; read errors return safe defaults. +- Member has 3 states: `ACTIVE` / `IDLE` / `TERMINATED`. + - `ACTIVE`: idle < 5 minutes + - `IDLE`: idle > 5 minutes + - `TERMINATED`: received `shutdown_response` with `approve: true` -### @dnd-kit and review transitions +### @dnd-kit and Review Transitions -- Переходы между review-колонками делаются через card actions в UI -- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки -- Phase 2: полноценный D&D через `@dnd-kit` +- Transitions between review columns happen through card actions in the UI. +- `@dnd-kit` is currently used primarily for reordering tasks inside a column. +- Phase 2: full drag-and-drop through `@dnd-kit`. --- -## Открытые вопросы +## Open Questions -- **FileWatcher расширение**: FileWatcher.ts уже 1243 строк — добавление teams/tasks watchers нетривиально, требует отдельного спайка -- **Windows atomic rename**: `fs.renameSync` на Windows бросает `EXDEV`/`EBUSY` при кросс-устройственном rename — нужна обёртка -- **leadSessionId интеграция**: config.json содержит `leadSessionId`, но интеграция с session viewer (переход к сессии лида) — открытый вопрос -- **Hard Interrupt**: сообщения доставляются между turns (1-30с задержка). В будущем нужен способ прервать mid-turn -- **Архивация**: inbox не чистится автоматически, нужна кнопка "Архивировать" +- **FileWatcher extension**: FileWatcher.ts is already 1243 lines. Adding teams/tasks watchers is non-trivial and needs a separate spike. +- **Windows atomic rename**: `fs.renameSync` on Windows can throw `EXDEV`/`EBUSY` for cross-device rename. A wrapper is needed. +- **leadSessionId integration**: config.json contains `leadSessionId`, but integration with the session viewer (navigating to the lead session) remains open. +- **Hard Interrupt**: messages are delivered between turns with a 1-30 second delay. A future mechanism is needed to interrupt mid-turn. +- **Archival**: inbox is not cleaned automatically. An "Archive" button is needed. -## Файловая структура Claude Code +## Claude Code File Structure -``` +```text ~/.claude/ ├── teams/{teamName}/ -│ ├── config.json # Конфиг команды (lead + служебные поля) -│ ├── members.meta.json # Роли/цвета/типы участников (teammates) -│ └── inboxes/{memberName}.json # Inbox каждого участника +│ ├── config.json # Team config (lead + service fields) +│ ├── members.meta.json # Member roles/colors/types (teammates) +│ └── inboxes/{memberName}.json # Inbox for each member └── tasks/{teamName}/ - ├── {id}.json # Файл задачи - ├── .lock # Lock-файл (0 байт) - └── .highwatermark # Последний ID задачи + ├── {id}.json # Task file + ├── .lock # Lock file (0 bytes) + └── .highwatermark # Latest task ID ``` -**ВАЖНО**: +**Important**: -- `config.json` не является source-of-truth для полного roster. -- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`. +- `config.json` is not the source of truth for the complete roster. +- The UI builds the complete roster from `members.meta.json + inbox filenames (+ lead from config)`. diff --git a/docs/team-management/research-messaging.md b/docs/team-management/research-messaging.md index ea5789e1..a5005fdf 100644 --- a/docs/team-management/research-messaging.md +++ b/docs/team-management/research-messaging.md @@ -1,43 +1,43 @@ -# Research: Подходы к отправке сообщений тиммейтам +# Research: Teammate Message Delivery Approaches -## Сравнение 3 подходов +## Comparison of 3 Approaches -| Критерий | Inbox-файлы | Agent SDK | CLI subprocess | -|----------|:-----------:|:---------:|:--------------:| -| Скорость | ~5ms | ~12с | 10-15с | -| Стоимость | $0 | $0.01-0.08/msg | токены | -| Работает с запущенными | **YES** | NO | NO | -| Прерывает mid-turn | NO | NO | NO | -| Требует API ключ | NO | YES | NO | -| Расход памяти | 0 | 0 | 100-320MB | +| Criterion | Inbox files | Agent SDK | CLI subprocess | +| --------- | :---------: | :-------: | :------------: | +| Speed | ~5ms | ~12s | 10-15s | +| Cost | $0 | $0.01-0.08/msg | tokens | +| Works with running teammates | **YES** | NO | NO | +| Interrupts mid-turn | NO | NO | NO | +| Requires API key | NO | YES | NO | +| Memory usage | 0 | 0 | 100-320MB | --- -## 1. Inbox-файлы (ВЫБРАНО) +## 1. Inbox Files (Chosen) -### Как работает +### How It Works -Прямая запись JSON в файл `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code мониторит эти файлы через fs.watch и доставляет сообщения агентам между turns. +The app writes JSON directly to `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code watches these files through fs.watch and delivers messages to agents between turns. -### Плюсы +### Pros -- **Мгновенная запись** (~5ms) -- **$0** — никаких API вызовов -- **Единственный** способ общаться с запущенными тиммейтами -- Работает с idle и active агентами (но доставка между turns) +- **Instant write** (~5ms) +- **$0** - no API calls +- **Only** way to communicate with already-running teammates +- Works with idle and active agents, although delivery still happens between turns -### Минусы +### Cons -- Race condition при одновременной записи (см. [research-inbox.md](./research-inbox.md)) -- Формат недокументирован (internal API) -- Доставка между turns, не real-time +- Race condition during concurrent writes (see [research-inbox.md](./research-inbox.md)) +- Undocumented format (internal API) +- Delivery happens between turns, not in real time -### Формат сообщения +### Message Format ```json { "from": "user", - "text": "Не трогай файл auth.ts, я его сам изменю", + "text": "Do not touch auth.ts, I will change it myself", "timestamp": "2026-02-17T15:30:00.000Z", "read": false, "summary": "Do not modify auth.ts", @@ -47,9 +47,9 @@ --- -## 2. Agent SDK (ОТВЕРГНУТ) +## 2. Agent SDK (Rejected) -### Как работает +### How It Works ```typescript import Anthropic from '@anthropic-ai/sdk'; @@ -57,156 +57,159 @@ const client = new Anthropic(); const response = await client.messages.create({ model: 'claude-opus-4-7', messages: [{ role: 'user', content: 'Send message to teammate...' }], - tools: [/* SendMessage, TaskUpdate, etc. */] + tools: [/* SendMessage, TaskUpdate, etc. */], }); ``` -### Почему отвергнут +### Why It Was Rejected -1. **Создаёт НОВУЮ сессию** — не подключается к работающему тиммейту. SendMessage и TaskCreate — это инструменты модели, не программные вызовы -2. **~12 секунд** на каждый вызов (полный API round-trip) -3. **Стоит токены** — $0.01-0.08 за сообщение -4. **Нужен API ключ** — отдельная оплата, а не подписка Claude +1. **Creates a new session** - does not attach to a running teammate. SendMessage and TaskCreate are model tools, not programmatic calls. +2. **~12 seconds** per call because of the full API round trip. +3. **Costs tokens** - $0.01-0.08 per message. +4. **Requires an API key** - separate billing, not a Claude subscription. -### Когда может пригодиться +### When It May Be Useful -- Создание новых команд программно -- Автоматизация workflow (вне real-time UI) +- Creating new teams programmatically. +- Workflow automation outside the real-time UI path. --- -## 3. CLI subprocess (ОТВЕРГНУТ) +## 3. CLI Subprocess (Rejected) -### Как работает +### How It Works ```bash claude --message "Send message to teammate-1: stop working on X" ``` -### Почему отвергнут +### Why It Was Rejected -1. **Новый процесс** — не инжектится в работающего тиммейта -2. **10-15 секунд** холодный старт -3. **100-320MB памяти** на процесс -4. Каждый вызов стоит токены +1. **New process** - does not inject into a running teammate. +2. **10-15 second** cold start. +3. **100-320MB** of memory per process. +4. Each call costs tokens. --- -## Архитектура доставки (обновлено 2026-03-23) +## Delivery Architecture (Updated 2026-03-23) -### Два разных механизма: лид vs тиммейты +### Two Different Mechanisms: Lead vs Teammates -**Лид** читает ТОЛЬКО stdin (stream-json). Для доставки сообщений лиду используется `relayLeadInboxMessages()` — конвертирует inbox-записи в stream-json на stdin. Без relay лид не видит inbox. +**Lead** reads ONLY stdin (stream-json). Messages to the lead are delivered with `relayLeadInboxMessages()`, which converts inbox entries into stream-json on stdin. Without relay, the lead does not see inbox messages. -**Тиммейты** — полноценные независимые Claude Code процессы. Каждый мониторит свой inbox файл через fs.watch и читает сообщения напрямую. Relay через лида НЕ нужен. +**Teammates** are fully independent Claude Code processes. Each teammate watches its own inbox file through fs.watch and reads messages directly. Relay through the lead is not needed. -### Поток сообщений: Юзер → Тиммейт +### Message Flow: User -> Teammate -``` -User → [UI] → TeamInboxWriter → inboxes/{member}.json (read: false) - ↓ - Teammate CLI (fs.watch) → читает → обрабатывает - ↓ - Teammate → inboxes/user.json (ответ) - ↓ - [UI] ← TeamInboxReader ← читает user.json +```text +User -> [UI] -> TeamInboxWriter -> inboxes/{member}.json (read: false) + | + Teammate CLI (fs.watch) -> reads -> handles + | + Teammate -> inboxes/user.json (response) + | + [UI] <- TeamInboxReader <- reads user.json ``` -Лид в этой цепочке НЕ участвует. Сообщение доставляется напрямую. +The lead is not part of this path. The message is delivered directly. -### Поток сообщений: Юзер → Лид +### Message Flow: User -> Lead -``` -User → [UI] → stdin (stream-json) → Lead CLI - ↓ -Lead → sentMessages.json / liveLeadProcessMessages - ↓ - [UI] ← читает и отображает +```text +User -> [UI] -> stdin (stream-json) -> Lead CLI + | +Lead -> sentMessages.json / liveLeadProcessMessages + | + [UI] <- reads and renders ``` -Для лида дополнительно работает `relayLeadInboxMessages()` при изменении `inboxes/{lead}.json`. +For the lead, `relayLeadInboxMessages()` additionally runs when `inboxes/{lead}.json` changes. -### Ответы тиммейтов +### Teammate Responses -Тиммейт отвечает юзеру через `SendMessage(to="user")`, что записывается в `inboxes/user.json`. UI читает этот файл через `TeamInboxReader.getMessages()` (читает ВСЕ inbox файлы в директории). +A teammate responds to the user through `SendMessage(to="user")`, which writes to `inboxes/user.json`. The UI reads this file through `TeamInboxReader.getMessages()`, which reads all inbox files in the directory. -Сообщения в `user.json` могут не содержать `messageId` — `TeamInboxReader` генерирует детерминированный ID из sha256(from + timestamp + text). +Messages in `user.json` may not contain `messageId`; `TeamInboxReader` generates a deterministic ID from sha256(from + timestamp + text). -### from: "user" — подтверждено работает +### from: "user" Is Confirmed To Work -`from: "user"` работает корректно (подтверждено эмпирически 2026-03-23): -- Тиммейт получает сообщение -- Тиммейт корректно определяет что это от юзера -- Тиммейт отвечает в `inboxes/user.json` -- Fallback на `from: "team-lead"` не нужен +`from: "user"` works correctly, confirmed empirically on 2026-03-23: -### Почему relay через лида был ОТКЛЮЧЁН (2026-03-23) +- Teammate receives the message. +- Teammate correctly identifies that it came from the user. +- Teammate responds in `inboxes/user.json`. +- Fallback to `from: "team-lead"` is not needed. -Ранее при отправке DM тиммейту, помимо записи в inbox, вызывался `relayMemberInboxMessages()` — инструкция лиду переслать сообщение через `SendMessage(to=member)`. Это вызывало 3 бага: +### Why Relay Through the Lead Was Disabled (2026-03-23) -1. **Лид отвечал вместо тиммейта** — LLM интерпретировал relay-инструкцию как обращение к себе и отвечал юзеру напрямую -2. **Дубликаты сообщений** — `markInboxMessagesRead()` записывал в файл → FileWatcher срабатывал → relay запускался повторно → цикл -3. **Тиммейт не отвечал юзеру** — relay-промпт содержал "Do NOT send to user", что тиммейт тоже видел через лида +Previously, when sending a DM to a teammate, the app called `relayMemberInboxMessages()` in addition to writing to the inbox. This instructed the lead to forward the message through `SendMessage(to=member)`. It caused 3 bugs: -Relay отключён в `teams.ts` (handleSendMessage) и `index.ts` (FileWatcher). Код закомментирован, не удалён. Relay для лида (`relayLeadInboxMessages`) не затронут. +1. **Lead replied instead of the teammate** - the LLM interpreted the relay instruction as addressed to itself and answered the user directly. +2. **Duplicate messages** - `markInboxMessagesRead()` wrote to the file, triggering FileWatcher, which re-ran relay and created a loop. +3. **Teammate did not reply to the user** - the relay prompt contained "Do NOT send to user", which the teammate also saw through the lead. + +Relay is disabled in `teams.ts` (`handleSendMessage`) and `index.ts` (FileWatcher). The code is commented out, not deleted. Lead relay (`relayLeadInboxMessages`) is unaffected. --- -## Доставка: Timing и ограничения +## Delivery: Timing and Constraints -### Цикл тиммейта +### Teammate Turn Cycle -``` +```text Turn N: - 1. Читает inbox → видит новые (read: false) - 2. Обрабатывает сообщения/задачи - 3. Вызывает инструменты + 1. Reads inbox -> sees new messages with read: false + 2. Handles messages/tasks + 3. Calls tools 4. Reasoning 5. Output - → idle_notification → IDLE + -> idle_notification -> IDLE -... ожидание ... +... wait ... Turn N+1: - 1. Пробуждение (новое сообщение в inbox / назначение задачи) - 2. Читает inbox → видит новые + 1. Wake-up (new inbox message / assigned task) + 2. Reads inbox -> sees new messages ... ``` -### Задержка +### Delay -- **Idle agent**: получит при следующем пробуждении (доли секунды если inbox-change triggers) -- **Active agent (mid-turn)**: получит только после завершения текущего turn (1-30 секунд) +- **Idle agent**: receives the message on the next wake-up, usually a fraction of a second if inbox-change triggers. +- **Active agent (mid-turn)**: receives the message only after the current turn completes, usually 1-30 seconds. -### Нельзя прервать +### No Mid-Turn Interrupt -Если агент уже вызвал Edit/Bash — инструмент выполнится. Наше сообщение придёт ПОСЛЕ. +If an agent has already called Edit/Bash, the tool will complete. Our message arrives after that. -**Пример**: -``` -17:12:30 — Agent начинает Edit на auth.ts -17:12:31 — Мы шлём "Не трогай auth.ts" -17:12:32 — Agent завершает Edit (auth.ts изменён) -17:12:33 — Agent читает inbox, видит наше сообщение -→ Поздно, файл уже изменён +**Example**: + +```text +17:12:30 - Agent starts Edit on auth.ts +17:12:31 - We send "Do not touch auth.ts" +17:12:32 - Agent completes Edit (auth.ts changed) +17:12:33 - Agent reads inbox and sees our message +-> Too late, the file was already changed ``` -### Hard Interrupt (будущее) +### Hard Interrupt (Future) -Возможные подходы: -1. **kill -SIGINT** процесса тиммейта (жёсткое прерывание, потеря контекста) -2. **Файловый flag** `.interrupt-{member}` (нужна поддержка в Claude Code) -3. **API от Anthropic** (если появится) +Possible approaches: -Текущее решение: задержка приемлема, hard interrupt — в будущем. +1. **kill -SIGINT** the teammate process: hard interrupt, context loss. +2. **File flag** `.interrupt-{member}`: needs Claude Code support. +3. **Anthropic API**: if it becomes available. + +Current decision: the delay is acceptable; hard interrupt is future work. --- -## Финальное решение +## Final Decision -### messageId — обязателен в каждом исходящем сообщении +### messageId Is Required In Every Outgoing Message -Каждое исходящее сообщение включает `messageId: crypto.randomUUID()`: +Every outgoing message includes `messageId: crypto.randomUUID()`: ```json { @@ -219,18 +222,18 @@ Turn N+1: } ``` -### Verify: проверка сразу после записи +### Verify Immediately After Write -- После atomic write читаем inbox и ищем наш `messageId` -- Если не найден — потеря обнаружена → warning в UI (не silent fail) -- Не автоматический retry на MVP +- After atomic write, read the inbox and look for our `messageId`. +- If missing, message loss was detected -> show a warning in the UI instead of failing silently. +- No automatic retry in MVP. -### 3 состояния offline-участника +### 3 States For Offline Members -| Состояние | Условие | Отображение | -|-----------|---------|-------------| -| `ACTIVE` | idle < 5 минут | Зелёный dot | -| `IDLE` | idle > 5 минут | Жёлтый dot | -| `TERMINATED` | Получен `shutdown_response` с `approve: true` | Серый dot, "Завершён" | +| State | Condition | Display | +| ----- | --------- | ------- | +| `ACTIVE` | idle < 5 minutes | Green dot | +| `IDLE` | idle > 5 minutes | Yellow dot | +| `TERMINATED` | Received `shutdown_response` with `approve: true` | Gray dot, "Terminated" | -Определение состояния по timestamp последнего события в inbox (idle_notification, любое сообщение). TERMINATED — исключительно по явному `shutdown_response`. +State is determined from the timestamp of the latest event in the inbox (`idle_notification` or any message). `TERMINATED` is based only on an explicit `shutdown_response`. diff --git a/src/features/CLAUDE.md b/src/features/CLAUDE.md index ec1c1b6e..32bbc565 100644 --- a/src/features/CLAUDE.md +++ b/src/features/CLAUDE.md @@ -19,13 +19,60 @@ Default location for new feature work: - `src/features//` -Before adding or moving code: +Before adding a medium or large feature: - decide whether the feature is full, thin, or process-limited -- add only the layers the feature actually owns -- expose production callers through public entrypoints only -- keep tests close to the layer they verify under `test/features//` or - feature-local `__tests__` when that is the established local pattern +- start with the layer set the feature actually owns; do not add placeholder + folders just to match the full template +- create explicit public entrypoints for every layer production callers need +- put shared DTOs, channel names, and API fragments in `contracts/` +- keep business policy in `core/domain` and use-case orchestration in + `core/application` +- keep Electron, IPC, HTTP, file system, process, and provider specifics outside + `core/` +- wire runtime dependencies from `main/composition/` when the feature owns main + process behavior +- expose preload bridges through `preload/index.ts` and renderer surfaces + through `renderer/index.ts` +- add focused tests for the layers that carry behavior + +When modifying an existing feature: + +- preserve the feature's current shape unless the change introduces a real new + boundary +- route app shell and cross-feature imports through public entrypoints +- move duplicated rules toward `core/domain` before adding another adapter copy +- keep transport validation and normalization close to the boundary that receives + the data +- update the feature README or local notes when the public surface or intended + shape changes +- keep local README examples concrete and file-based; link back to the standard + for architecture rules instead of restating them + +Public entrypoint expectations: + +- `contracts/index.ts` exports only browser-safe contracts, constants, and + normalizers intended for cross-process use +- `main/index.ts` exports composition and registration surfaces for main-process + callers +- `preload/index.ts` exports bridge creation only +- `renderer/index.ts` exports reusable renderer components, hooks, or utilities + that are intentionally consumed outside the feature +- root `index.ts` is optional; use it only when the feature deliberately owns a + stable public barrel + +Testing expectations: + +- test pure domain rules directly and keep those tests independent of runtime + services +- test application use cases with ports or fakes, not Electron or real provider + processes +- test adapter mapping, boundary normalization, and renderer utilities where they + can regress user-visible behavior +- prefer `test/features//...` for cross-layer coverage; feature-local + `__tests__` are fine when the surrounding feature already uses that pattern +- for docs-only changes, verify links and examples instead of running broad test + suites Do not duplicate architecture rules here. Keep architecture rules centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). diff --git a/src/features/README.md b/src/features/README.md index 4e9851ff..8901e31d 100644 --- a/src/features/README.md +++ b/src/features/README.md @@ -3,22 +3,38 @@ This directory contains the canonical home for medium and large feature slices. Before creating or refactoring a feature, read: + - [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) - [Feature-local agent guidance](./CLAUDE.md) -Reference implementation: -- `src/features/recent-projects` -- `src/features/agent-graph` +Reference examples: + +- [`recent-projects`](./recent-projects/README.md) - full cross-process feature + with contracts, core, main, preload, renderer, and focused tests +- [`agent-graph`](./agent-graph/README.md) - thin feature with `core/domain` and + renderer integration only +- `codex-model-catalog` and `team-runtime-lanes` - process-limited features + that omit renderer or preload layers when they do not own those boundaries Use `src/features//` by default when the work introduces: + - a new use case or business policy - transport wiring - more than one process boundary - more than one adapter or provider +Feature-local docs should answer navigation questions: + +- which shape the feature uses +- which entrypoints are public +- where new adapters, rules, bridges, or renderer surfaces belong +- what tests protect the behavior +- which local files are the best examples for future changes + Do not duplicate architecture rules in feature folders. Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). Rule of thumb: + - `recent-projects` is the full slice example with process-aware outer layers - `agent-graph` is the thin slice example built around `core/` plus `renderer/` diff --git a/src/features/recent-projects/README.md b/src/features/recent-projects/README.md new file mode 100644 index 00000000..61a148d7 --- /dev/null +++ b/src/features/recent-projects/README.md @@ -0,0 +1,108 @@ +# Recent Projects Feature + +`recent-projects` is the full cross-process reference for +[`docs/FEATURE_ARCHITECTURE_STANDARD.md`](../../../docs/FEATURE_ARCHITECTURE_STANDARD.md). +Use it as the local example when a feature owns contracts, pure business rules, +runtime composition, transport adapters, preload bridging, and renderer UI. + +Read this with: + +- [Feature root guide](../README.md) +- [Feature-local agent guidance](../CLAUDE.md) + +## Feature Shape + +```text +src/features/recent-projects/ + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +This feature intentionally does not have a root `index.ts`. Production callers +enter through the layer-specific public entrypoints: + +- `contracts/index.ts` for DTOs, channels, API fragments, and payload + normalization +- `main/index.ts` for main-process registration and composition +- `preload/index.ts` for bridge creation +- `renderer/index.ts` for renderer-owned UI and public renderer utilities + +## Layer Examples + +- `core/domain/policies/mergeRecentProjectCandidates.ts` owns provider-agnostic + merge policy and stays pure +- `core/application/use-cases/ListDashboardRecentProjectsUseCase.ts` + orchestrates ports and response models without importing runtime details +- `main/composition/createRecentProjectsFeature.ts` wires infrastructure, + adapters, ports, and use cases for the main process +- `main/adapters/input/ipc/registerRecentProjectsIpc.ts` and + `main/adapters/input/http/registerRecentProjectsHttp.ts` translate transport + requests into feature calls +- `main/adapters/output/sources/*` adapts provider/runtime data into the core + model +- `main/infrastructure/cache/InMemoryRecentProjectsCache.ts` and + `main/infrastructure/identity/*` keep runtime-specific helpers out of `core/` +- `preload/createRecentProjectsBridge.ts` exposes the feature API fragment to + the renderer +- `renderer/hooks/useRecentProjectsSection.ts` coordinates renderer interaction + and data access +- `renderer/ui/RecentProjectsSection.tsx` keeps the visual component focused on + rendering and callbacks + +## How To Extend It + +When adding another source or provider: + +- add or reuse a port in `core/application/ports/` +- keep provider-specific parsing in `main/adapters/output/` or + `main/infrastructure/` +- keep merge, ordering, dedupe, and selection rules in `core/domain/` +- wire the new dependency in `main/composition/createRecentProjectsFeature.ts` +- add focused tests beside the layer that owns the behavior + +When adding another transport: + +- put shared request/response shape in `contracts/` +- implement the input adapter under `main/adapters/input/` +- keep handler registration out of `core/` +- expose only the stable surface from `main/index.ts` + +When changing renderer behavior: + +- keep data fetching and app API calls in hooks or renderer adapters +- keep UI components presentational +- transform DTOs into view models before they reach reusable UI where practical +- update renderer utility tests when sorting, navigation, active-team state, or + client cache behavior changes + +When updating this reference: + +- keep examples tied to real files in this feature +- update this README when public entrypoints or intended extension paths change +- leave cross-feature architecture wording in the shared standard + +## Test Map + +Reference tests live under `test/features/recent-projects/`: + +- `contracts/` covers payload normalization +- `core/domain/` covers merge policy +- `core/application/` covers use-case orchestration through ports +- `main/adapters/output/` and `main/infrastructure/` cover provider and runtime + integration boundaries with fakes +- `renderer/adapters/` and `renderer/utils/` cover view-model mapping and + interaction helpers + +For new medium or large features, this test shape is a good starting point: +domain rules first, application use cases second, then focused adapter and +renderer utility coverage for behavior that can break user workflows. diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index f6eb8a3f..ad187f70 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; @@ -20,18 +20,11 @@ export const RecentProjectCard = ({ onOpenPath, }: Readonly): React.JSX.Element => { const color = useMemo(() => projectColor(card.name), [card.name]); - const [isHovered, setIsHovered] = useState(false); return ( + + + {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} + + + ) : null} + {status === 'active' || status === 'idle' ? ( + + + + + + {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} + + + ) : null} + {!team.pendingCreate ? ( + + + + + Copy team + + ) : null} + + + + + Delete team + + + + +
+

+ {team.description || 'No description'} +

+
+ {team.teamLaunchState === 'partial_pending' ? ( +

+ {team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0 + ? buildPendingRuntimeSummaryCopy({ + confirmedCount: team.confirmedCount, + expectedMemberCount: team.expectedMemberCount, + memberCount: team.memberCount, + runtimeProcessPendingCount: team.runtimeProcessPendingCount, + includePeriod: true, + }) + : 'Last launch is still reconciling.'} +

+ ) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? ( +

+ {team.missingMembers?.length + ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.` + : 'Last launch stopped before all teammates joined.'} +

+ ) : team.teamLaunchState === 'partial_skipped' ? ( +

+ {team.skippedMembers?.length + ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.` + : 'Last launch has skipped teammates.'} +

+ ) : null} +
+ {team.members && team.members.length > 0 ? ( + renderMemberChips(team.members, isLight) + ) : team.memberCount === 0 ? ( + + Solo + + ) : ( + + Members: {team.memberCount} + + )} +
+
+ + {renderTeamRecentPaths(team, status, matchesCurrentProject, isLight, currentProjectPath)} +
+ + + ); +}; + export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); @@ -862,186 +1093,88 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { const activeFiltered = filteredTeams.filter((t) => !t.deletedAt); const deletedFiltered = filteredTeams.filter((t) => t.deletedAt); + const activeSections = currentProjectPath + ? [ + { + key: 'project', + title: `Teams for ${folderName(currentProjectPath) || 'selected project'}`, + teams: activeFiltered.filter((team) => + teamMatchesProjectSelection(team, currentProjectPath) + ), + }, + { + key: 'other', + title: 'Other teams', + teams: activeFiltered.filter( + (team) => !teamMatchesProjectSelection(team, currentProjectPath) + ), + }, + ].filter((section) => section.teams.length > 0) + : [ + { + key: 'all', + title: null, + teams: activeFiltered, + }, + ]; return ( <> -
- {activeFiltered.map((team) => { - const status = resolveTeamStatus( - team, - team.teamName, - aliveTeams, - getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), - leadActivityByTeam - ); - const teamColorSet = team.color - ? getTeamColorSet(team.color) - : nameColorSet(team.displayName); - const matchesCurrentProject = currentProjectPath - ? teamMatchesProjectSelection(team, currentProjectPath) - : false; - return ( -
openTeamTab(team.teamName, team.projectPath)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openTeamTab(team.teamName, team.projectPath); - } - }} - > -
-
-
-

- {team.displayName} -

- - {team.projectPath && - (() => { - const branch = branchByPath[normalizePath(team.projectPath)]; - if (!branch) return null; - return ( - - - {branch} - - ); - })()} -
-
- {(status === 'offline' || - status === 'partial_failure' || - status === 'partial_skipped' || - status === 'partial_pending') && - team.projectPath && ( - - - - - - {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} - - - )} - {(status === 'active' || status === 'idle') && ( - - - - - - {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} - - - )} - {!team.pendingCreate && ( - - - - - Copy team - - )} - - - - - Delete team - -
-
-
-

- {team.description || 'No description'} -

-
- {team.teamLaunchState === 'partial_pending' ? ( -

- {team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0 - ? buildPendingRuntimeSummaryCopy({ - confirmedCount: team.confirmedCount, - expectedMemberCount: team.expectedMemberCount, - memberCount: team.memberCount, - runtimeProcessPendingCount: team.runtimeProcessPendingCount, - includePeriod: true, - }) - : 'Last launch is still reconciling.'} -

- ) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? ( -

- {team.missingMembers?.length - ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.` - : 'Last launch stopped before all teammates joined.'} -

- ) : team.teamLaunchState === 'partial_skipped' ? ( -

- {team.skippedMembers?.length - ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.` - : 'Last launch has skipped teammates.'} -

- ) : null} -
- {team.members && team.members.length > 0 ? ( - renderMemberChips(team.members, isLight) - ) : team.memberCount === 0 ? ( - - Solo - - ) : ( - - Members: {team.memberCount} - - )} -
-
- - {renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)} -
-
+ {activeSections.map((section, sectionIndex) => ( +
0 ? 'mt-6' : undefined}> + {section.title ? ( +
+

+ {section.title} +

+ + {section.teams.length} +
- ); - })} -
+ ) : null} +
+ {section.teams.map((team) => { + const status = resolveTeamStatus( + team, + team.teamName, + aliveTeams, + getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), + leadActivityByTeam + ); + const teamColorSet = team.color + ? getTeamColorSet(team.color) + : nameColorSet(team.displayName); + const matchesCurrentProject = currentProjectPath + ? teamMatchesProjectSelection(team, currentProjectPath) + : false; + return ( + + ); + })} +
+ + ))} {deletedFiltered.length > 0 && ( <> @@ -1105,7 +1238,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { {team.description || 'No description'}

{team.members && team.members.length > 0 && ( -
+
{renderMemberChips(team.members, isLight)}
)} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index d4c106f9..69154692 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -15,7 +15,7 @@ import { } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Layers } from 'lucide-react'; +import { Layers, Loader2 } from 'lucide-react'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext'; @@ -126,6 +126,8 @@ interface ActivityTimelineProps { onExpandItem?: (key: string) => void; /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ onExpandContent?: () => void; + /** True while the initial message page is loading and no cached rows are available yet. */ + loading?: boolean; /** * Optional viewport contract. When provided, IntersectionObserver uses the * passed `observerRoot` instead of the document viewport, which is required @@ -167,6 +169,31 @@ const ROW_SIZE_ESTIMATES: Record = { 'message-row': 140, }; +const TimelineLoadingState = (): React.JSX.Element => ( +
+
+ + Loading messages... +
+