diff --git a/.gitignore b/.gitignore index 28223886..a751acb0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ temp/ eslint-fix/ -remotion/* \ No newline at end of file +remotion/* + +.home/ \ No newline at end of file diff --git a/docs/iterations/README.md b/docs/iterations/README.md index 4f29b7bd..e809b9f9 100644 --- a/docs/iterations/README.md +++ b/docs/iterations/README.md @@ -5,6 +5,11 @@ ## Список - [Итерация 01 — Core Foundation + Team List](./iteration-01-core-team-list.md) +- [Итерация 02 — Team Detail + Members](./iteration-02-team-detail-members.md) +- [Итерация 03 — Kanban Board (click-to-move)](./iteration-03-kanban-board.md) +- [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md) +- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md) +- [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md) ## Принципы diff --git a/docs/iterations/iteration-02-team-detail-members.md b/docs/iterations/iteration-02-team-detail-members.md new file mode 100644 index 00000000..28925499 --- /dev/null +++ b/docs/iterations/iteration-02-team-detail-members.md @@ -0,0 +1,334 @@ +# Итерация 02 — Team Detail + Members + +Эта итерация добавляет **детальную страницу команды**: по клику из списка команд открывается вкладка команды, где видно **участников** и **задачи**. Плюс включаем **live refresh** через file watcher для `~/.claude/teams` и `~/.claude/tasks`. + +Основание: `docs/team-management/implementation.md` (Iteration 2) + research-доки в `docs/team-management/`. + +--- + +## Цель итерации + +- Из `Teams` списка можно кликнуть команду → открывается **Team tab** (тип `team`) +- В Team tab отображаются: + - **участники** (минимум: имя + текущая задача/статус-заглушка) + - **задачи** (список задач из `~/.claude/tasks/{team}`) +- При изменении файлов в `~/.claude/teams/**` или `~/.claude/tasks/**` UI автоматически обновляется (с coalesce/throttle) + +--- + +## Не-цели (строго вне scope) + +- Kanban и `kanban-state.json` (это итерация 03) +- Чтение/рендер сообщений inbox, compose message, review flow (итерация 04) +- Любые write-path (в inbox, в task status, в kanban state) +- Полный refactor Tab-types в discriminated union (это отдельная тех-итерация, не часть MVP) +- Полное покрытие тестами для Team Management (итерация 05). В этой итерации пишем тесты **только если** что-то ломается и требует стабилизации. + +--- + +## Контракт итерации (Main → Preload → Renderer) + +### IPC каналы (flat `export const`) + +В `src/preload/constants/ipcChannels.ts`: + +- `TEAM_GET_DATA = 'team:getData'` +- `TEAM_CHANGE = 'team:change'` (event main → renderer) + +### Нейминг событий (без двусмысленностей) + +- **IPC (Electron)**: канал `TEAM_CHANGE = 'team:change'` (строка с двоеточием) +- **SSE (HTTP sidecar)**: событие `team-change` (строка с дефисом), потому что в проекте уже используется этот стиль для SSE (`file-change`, `todo-change`, `notification:new`) + +Требование: main process форвардит **одно и то же payload-содержимое** в оба транспорта. + +### Shared types + +В `src/shared/types/team.ts` вводим/расширяем типы (для итерации 02 — только нужный минимум): + +- `TeamSummary` (обновляем контракт из итерации 01, см. ниже) +- `TeamTask` +- `ResolvedTeamMember` +- `TeamData` (без kanban/inbox messages) +- `TeamChangeEvent` + +#### TeamSummary (обязательный migration) + +В итерации 01 поле `TeamSummary.name` фактически использовалось как “display name”. Для итерации 02 нам нужен стабильный идентификатор для доступа к диску. + +**Новый контракт TeamSummary:** + +- `teamName: string` — **directory name** (`~/.claude/teams/{teamName}`) +- `displayName: string` — `config.json.name` (человеческое имя) +- `description: string` +- `memberCount: number` +- `taskCount: number` (в итерации 02 можно оставить 0, либо заполнить после чтения задач) +- `lastActivity: string | null` (в итерации 02 остаётся `null`) + +**Обязательная правка в этой итерации:** обновить реализацию `team:list` и UI, чтобы клик открывал Team tab по `teamName`, а отображение было по `displayName`. + +#### TeamTask (читает Claude Code task file) + +- `id: string` +- `subject: string` +- `description?: string` +- `activeForm?: string` +- `owner?: string` +- `status: 'pending' | 'in_progress' | 'completed' | 'deleted'` +- `blocks?: string[]` +- `blockedBy?: string[]` + +#### ResolvedTeamMember (итерация 02) + +- `name: string` +- `status: 'unknown'` (в итерации 02 **всегда** unknown; статусы по inbox — итерация 04) +- `currentTaskId: string | null` (берём первую `in_progress` задачу по `owner === name`) +- `taskCount: number` (кол-во задач по `owner === name`, исключая `deleted`) + +#### TeamData (итерация 02) + +- `teamName: string` (канонический идентификатор — **имя директории** в `~/.claude/teams/{teamName}`) +- `config: TeamConfig` +- `tasks: TeamTask[]` +- `members: ResolvedTeamMember[]` +- `warnings?: string[]` (например, “tasks failed to load”) + +#### TeamChangeEvent + +- `type: 'config' | 'inbox' | 'task'` +- `teamName: string` +- `detail?: string` (например `inboxes/alice.json` или `12.json`) + +### TeamsAPI (shared `src/shared/types/api.ts`) + +Расширяем интерфейс `TeamsAPI`: + +- `getData: (teamName: string) => Promise` +- `onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void` + +`list()` остаётся как есть. + +--- + +## Важное решение без двусмысленностей: идентификатор = teamName (directory name) + +Вся адресация на диске идёт по `teamName`, который равен имени директории: + +- `~/.claude/teams/{teamName}/config.json` +- `~/.claude/teams/{teamName}/inboxes/*.json` +- `~/.claude/tasks/{teamName}/*.json` + +**Следствие:** в `team:list` должен быть явный `teamName` (см. новый `TeamSummary`), и все навигации/IPC используют именно его. + +--- + +## Definition of Done (DoD) + +- **UI** + - В `Teams` списке клик по карточке открывает Team tab + - Team tab показывает MemberList и TasksList (или таблицу) + - Есть пустые/ошибочные состояния: “нет задач”, “не удалось загрузить” +- **IPC** + - `TEAM_GET_DATA` работает, валидирует аргументы, возвращает `TeamData` + - `TEAM_CHANGE` событие приходит в renderer при изменениях teams/tasks +- **FileWatcher** + - Добавлены `teamsWatcher` и `tasksWatcher` (v7 fix #35) + - В local режиме используют `fs.watch(..., { recursive: true })` + - В SSH режиме эти watchers **не запускаются** +- **Store** + - Есть `selectTeam(teamName)` и `refreshTeamData(teamName)` + - Есть coalesce/throttle 300ms на `TEAM_CHANGE` (v7 fix #46) +- **Качество** + - `pnpm typecheck` проходит + - `pnpm test` проходит (регрессия не допущена) + +--- + +## Выходные изменения (файлы) — что добавляем/меняем + +### Новые файлы + +- `src/main/services/team/TeamTaskReader.ts` +- `src/main/services/team/TeamInboxReader.ts` (в итерации 02 — только `listInboxNames()`) +- `src/main/services/team/TeamMemberResolver.ts` +- `src/renderer/components/team/TeamDetailView.tsx` +- `src/renderer/components/team/members/MemberList.tsx` +- `src/renderer/components/team/members/MemberCard.tsx` +- `src/renderer/components/team/tasks/TaskList.tsx` +- `src/renderer/components/team/tasks/TaskRow.tsx` + +### Изменяемые файлы + +- `src/shared/types/team.ts` (расширение типов) +- `src/main/utils/pathDecoder.ts` (+ `getTasksBasePath()`) +- `src/main/services/team/TeamConfigReader.ts` (обновить `listTeams()` под новый `TeamSummary`, + `getConfig(teamName)`) +- `src/main/services/team/TeamDataService.ts` (+ `getTeamData(teamName)`) +- `src/main/ipc/guards.ts` (+ `validateTeamName()` для teamName) +- `src/main/ipc/teams.ts` (+ handler `TEAM_GET_DATA`) +- `src/main/ipc/handlers.ts` (register/remove остаётся; добавить ничего нового кроме импорта константы/инициализации не нужно) +- `src/preload/constants/ipcChannels.ts` (+ `TEAM_GET_DATA`, `TEAM_CHANGE`) +- `src/preload/index.ts` (добавить `teams.getData()` и `teams.onTeamChange()`) +- `src/main/services/infrastructure/FileWatcher.ts` (добавить `teamsWatcher/tasksWatcher`, emit `team-change`) +- `src/main/index.ts` (wire `team-change` forwarding + httpServer.broadcast) +- `src/renderer/store/slices/teamSlice.ts` (расширить slice: selected team + refresh + throttling hooks) +- `src/renderer/store/index.ts` (в `initializeNotificationListeners()` подписка на `api.teams.onTeamChange`) +- `src/renderer/types/tabs.ts` (добавить `type: 'team'` + поле `teamName?: string`) +- `src/renderer/components/layout/PaneContent.tsx` (рендер `TeamDetailView` для `tab.type === 'team'`) +- `src/renderer/components/layout/SortableTab.tsx` (иконка для `team`) +- `src/renderer/components/team/TeamListView.tsx` (клик по карточке → `openTeamTab(team.teamName)`, отображение `team.displayName`) +- `src/renderer/api/httpClient.ts` (добавить заглушки для `getData/onTeamChange`) + +--- + +## Порядок работ (runbook) с контрольными точками + +### CP0 — типы компилируются + +1) **Shared types** + - Обновить `TeamSummary` на `{ teamName, displayName, ... }` (migration из итерации 01) + - Расширить `src/shared/types/team.ts` новыми типами (см. “Контракт”) + - Расширить `src/shared/types/api.ts` (`TeamsAPI.getData`, `TeamsAPI.onTeamChange`) + +2) `pnpm typecheck` + +--- + +### CP1 — backend + IPC возвращают TeamData + +3) **Path helpers** + - Добавить `getTasksBasePath()` в `src/main/utils/pathDecoder.ts`: + - `return path.join(getClaudeBasePath(), 'tasks')` + +4) **Readers + resolver** + - `TeamConfigReader.listTeams()`: + - `teamName = entry.name` (directory name) + - `displayName = config.name` + - `description`/`memberCount` как раньше + - `TeamConfigReader.getConfig(teamName)` читает `config.json`, возвращает `TeamConfig | null` + - `TeamTaskReader.getTasks(teamName)` читает `~/.claude/tasks/{teamName}`: + - если директории нет → `[]` (v7 fix #38) + - пропускать `.lock`, `.highwatermark`, скрытые файлы + - пропускать задачи со `status === 'deleted'` + - `TeamInboxReader.listInboxNames(teamName)`: + - читает `~/.claude/teams/{teamName}/inboxes` + - возвращает имена пользователей по файлам `*.json` без расширения + - если директории нет → `[]` + - `TeamMemberResolver.resolveMembers(config, inboxNames, tasks)`: + - строит union имён: `config.members[].name` + `inboxNames` + `task.owner` + - вычисляет `taskCount` и `currentTaskId` + - `status` = `'unknown'` для всех + +5) **TeamDataService.getTeamData(teamName)** + - `config` обязателен: если нет → throw Error “Team not found” + - `tasks`/`inboxNames` грузятся с graceful fallback и `warnings[]` + - `members` строится резолвером + +6) **IPC** + - В `src/main/ipc/guards.ts` добавить `validateTeamName()` (паттерн как `validateSessionId`, но для `teamName`) + - В `src/main/ipc/teams.ts`: + - добавить `TEAM_GET_DATA` + - handler `getData(teamName)`: + - валидирует teamName + - вызывает `teamDataService.getTeamData(teamName)` + - возвращает `IpcResult` + +7) **Preload** + - `src/preload/constants/ipcChannels.ts`: добавить `TEAM_GET_DATA`, `TEAM_CHANGE` + - `src/preload/index.ts`: + - `teams.getData(teamName)` через `invokeIpcWithResult(TEAM_GET_DATA, teamName)` + - `teams.onTeamChange(cb)` подписывается на `TEAM_CHANGE` и возвращает cleanup + +8) Быстрая проверка: + - в DevTools: `await window.electronAPI.teams.getData('')` + +9) `pnpm typecheck` + +--- + +### CP2 — FileWatcher шлёт team-change, store обновляет данные + +10) **FileWatcher: teamsWatcher + tasksWatcher** + - В `src/main/services/infrastructure/FileWatcher.ts`: + - добавить `teamsWatcher/tasksWatcher` свойства + - в `stop()/dispose()` закрыть их + - в `ensureWatchers()` в local режиме запускать оба watcher’а + - watchers используют `fs.watch(path, { recursive: true })` + - emit: `this.emit('team-change', teamChangeEvent)` (EventEmitter name именно `team-change`) + - события классифицировать: + - teamsWatcher: `config` если `config.json`, `inbox` если внутри `inboxes/` + - tasksWatcher: всегда `task` + - teamName извлекать как первый сегмент пути `filename.split(/[\\/]/)[0]` + - Поведение при отсутствии директории: + - если `~/.claude/teams` или `~/.claude/tasks` отсутствует — это НЕ ошибка, watcher просто не стартует и планирует retry (как todos) + +11) **Main: forwarding** + - В `src/main/index.ts` (в `wireFileWatcherEvents`): + - добавить forwarding для `'team-change'`: + - `mainWindow.webContents.send(TEAM_CHANGE /* 'team:change' */, event)` + - `httpServer.broadcast('team-change', event)` + - обязательно cleanup при rewire (как fileChangeCleanup/todoChangeCleanup) + +12) **Renderer store: подписка + throttle** + - В `src/renderer/store/index.ts` внутри `initializeNotificationListeners()`: + - подписаться на `api.teams.onTeamChange` (если существует) + - coalesce 300ms: + - всегда `fetchTeams()` (лёгкая операция) + - если активен Team tab данного teamName → вызвать `refreshTeamData(teamName)` + +13) `pnpm typecheck` + +--- + +### CP3 — UI: Team tab + MemberList + TaskList + +14) **Tabs** + - `src/renderer/types/tabs.ts`: + - добавить `type: 'team'` + - добавить поле `teamName?: string` (инвариант: если `type==='team'`, то строка непустая) + - `src/renderer/components/layout/PaneContent.tsx`: + - `tab.type === 'team'` → `TeamDetailView teamName={tab.teamName ?? ''}` + - если `teamName` пустой → показываем “Invalid team tab” (error state) + - `src/renderer/components/layout/SortableTab.tsx`: + - добавить иконку для `team` + +15) **TeamSlice** + - Расширить `src/renderer/store/slices/teamSlice.ts`: + - `selectedTeamName: string | null` + - `selectedTeamData: TeamData | null` + - `selectedTeamLoading: boolean` + - `selectedTeamError: string | null` + - `selectTeam(teamName)` → вызывает `api.teams.getData` + - `refreshTeamData(teamName)` → re-fetch если выбран тот же teamName + - `openTeamTab(teamName)`: + - ищет существующий `tab.type==='team' && tab.teamName===teamName` во всех panes → фокусирует + - иначе `openTab({ type:'team', label: teamName, teamName })` + +16) **UI компоненты** + - `TeamListView`: + - карточка команды кликабельна и вызывает `openTeamTab(team.teamName)` + - заголовок карточки показывает `team.displayName` + - `TeamDetailView`: + - `useEffect`: `selectTeam(teamName)` + - 4 состояния: loading / error / empty / data + - layout: слева `MemberList`, справа `TaskList` + - `MemberList/MemberCard`: + - имя + “unknown” статус + текущая задача (если есть) + - `TaskList/TaskRow`: + - таблица/лист: id, subject, owner, status, blocked (если `blockedBy.length>0`) + +17) `pnpm test` + +18) Ручная проверка (обязательная) + - Открыть `Teams` → кликнуть команду → открылся Team tab + - Видно участников (включая владельцев задач, даже если их нет в config.members) + - Видны задачи из `~/.claude/tasks/{teamName}` + - Потрогать файл задачи (изменить owner/status) → UI обновился в течение ~300ms–1s + +--- + +## Риски и митигации + +- **Имя команды ≠ имя директории**: решено контрактом `TeamSummary.teamName` (dir) + `TeamSummary.displayName` (человекочитаемо). +- **fs.watch пропуски событий**: есть existing catch-up scan для sessions; для teams/tasks в этой итерации полагаемся на debounce+coalesce, полноценный catch-up можно добавить позже при необходимости. +- **Шумные события**: coalesce 300ms в renderer, чтобы не спамить refresh. + diff --git a/docs/iterations/iteration-03-kanban-board.md b/docs/iterations/iteration-03-kanban-board.md new file mode 100644 index 00000000..5f14a654 --- /dev/null +++ b/docs/iterations/iteration-03-kanban-board.md @@ -0,0 +1,266 @@ +# Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json` + +Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`. + +Основание: +- `docs/team-management/implementation.md` (**Iteration 3: Kanban Board**) +- `docs/team-management/kanban-design.md` (колонки, storage, mapping, GC) +- `docs/team-management/research-tasks.md` (ограничения task-файлов, почему не metadata) + +--- + +## Цель итерации + +- Во вкладке Team появляется **Kanban** с 5 колонками: + - `TODO`, `IN PROGRESS`, `DONE` — **авто-маппинг** из `task.status` + - `REVIEW`, `APPROVED` — управляются **только** через `kanban-state.json` +- Поддерживаем MVP UX: **click-to-move** (select/dropdown на карточке). +- Перемещения пишутся в `~/.claude/teams/{teamName}/kanban-state.json` (atomic write). +- Live refresh уже существующий (`team:change`) обновляет kanban при изменениях tasks/teams. + +--- + +## Не-цели (строго вне scope) + +- Drag-and-drop (Phase 2, `@dnd-kit`). +- Отправка сообщений ревьюверу, MessageComposer, ActivityTimeline (это итерация 04). +- Изменение `task.status` (Fix flow) и любые write-path в `~/.claude/tasks/**` (это итерация 04). +- `reviewHistory`, round-robin балансировка ревьюверов (Phase 2). +- Создание новых задач (из-за `.highwatermark` риска, см. `research-tasks.md`). + +--- + +## Контракт итерации (Main → Preload → Renderer) + +### Storage: путь и формат + +- **Файл**: `~/.claude/teams/{teamName}/kanban-state.json` +- Причина: task `metadata` может быть перезаписан агентом (см. `research-tasks.md`). + +Минимальный формат (под MVP): + +- `teamName: string` +- `reviewers: string[]` (пока только хранение/чтение; управление — итерация 04) +- `tasks: Record` + +`KanbanTaskState` (MVP): +- `column: 'review' | 'approved'` +- `reviewStatus?: 'pending' | 'error'` +- `reviewer?: string | null` +- `errorDescription?: string` +- `movedAt: string` (ISO) + +Примечание по scope: +- В итерации 03 мы **пишем только** `reviewStatus: 'pending'` при переходе в `REVIEW`. +- Поля `reviewer` и `errorDescription` закладываем в формат, но не обязаны полноценно использовать до итерации 04. + +### Column mapping (единый источник правды) + +Логика маппинга колонки для задачи: + +1) Если `kanbanState.tasks[task.id]` есть → используем `column` из state (`review|approved`) +2) Иначе: + - `pending` → `todo` + - `in_progress` → `in_progress` + - `completed` → `done` + - `deleted` → не показываем + +Полностью соответствует `docs/team-management/kanban-design.md`. + +### Shared types (`src/shared/types/team.ts`) + +Добавляем типы: + +- `KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'` +- `KanbanTaskState` +- `KanbanState` + +И расширяем `TeamData`: + +- `kanbanState: KanbanState` + +### IPC / TeamsAPI + +Добавляем канал и метод для обновления kanban state: + +- `TEAM_UPDATE_KANBAN = 'team:updateKanban'` +- `TeamsAPI.updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise` + +`UpdateKanbanPatch` (строго фиксируем, без “и так понятно”): +- `{ op: 'set_column'; column: 'review' | 'approved' }` +- `{ op: 'remove' }` + +Поведение: +- `set_column: review` → записываем `{ column:'review', reviewStatus:'pending', reviewer:null, movedAt: now }` +- `set_column: approved` → записываем `{ column:'approved', movedAt: now }` (reviewStatus/reviewer можно очистить) +- `remove` → удаляем запись для `taskId` из `kanbanState.tasks` + +Требования: +- Валидация `teamName` (уже есть). +- Валидация `taskId` (добавляем `validateTaskId()`). +- IPC возвращает `IpcResult` (единый паттерн проекта). +- Browser-mode: метод должен возвращать понятную ошибку “доступно только в Electron”, как и остальной Teams API. + +--- + +## Definition of Done (DoD) + +- **UI** + - В Team tab виден kanban с 5 колонками. + - Карточки задач появляются в корректных колонках по mapping-логике. + - На карточке в `DONE` есть `Move to: REVIEW` (click-to-move). + - На карточке в `REVIEW` есть `Move to: APPROVED` и `Move back` (remove state → вернётся в `DONE` по status). + - В `REVIEW` показывается `ReviewBadge` со статусом `Pending` (manual review; без авто-назначения в итерации 03). + - Пустые состояния: “нет задач”, “не удалось загрузить kanban”. +- **Backend/IPC** + - `TEAM_GET_DATA` возвращает `TeamData` с `kanbanState`. + - `TEAM_UPDATE_KANBAN` обновляет `kanban-state.json` атомарно. + - GC kanban-state выполняется **только после** полной загрузки `tasks` (см. `kanban-design.md`). +- **Live refresh** + - Изменение `kanban-state.json` приводит к refresh team data (через существующий `team:change` → store). +- **Качество** + - `pnpm typecheck` проходит. + - `pnpm test` проходит. + +--- + +## Выходные изменения (файлы) + +### Новые файлы + +- `src/main/services/team/TeamKanbanManager.ts` +- `src/main/services/team/atomicWrite.ts` (минимум для `kanban-state.json`, расширим в итерации 04 под inbox/task write) +- `src/renderer/components/team/kanban/KanbanBoard.tsx` +- `src/renderer/components/team/kanban/KanbanColumn.tsx` +- `src/renderer/components/team/kanban/KanbanTaskCard.tsx` +- `src/renderer/components/team/kanban/ReviewBadge.tsx` + +### Изменяемые файлы + +- `src/shared/types/team.ts` (kanban types + `TeamData.kanbanState`) +- `src/shared/types/api.ts` (TeamsAPI.updateKanban) +- `src/preload/constants/ipcChannels.ts` (+ `TEAM_UPDATE_KANBAN`) +- `src/preload/index.ts` (bridge `teams.updateKanban`) +- `src/main/ipc/guards.ts` (+ `validateTaskId()`) +- `src/main/ipc/teams.ts` (+ handler `TEAM_UPDATE_KANBAN`) +- `src/main/services/team/TeamDataService.ts` (подключить kanban manager, GC) +- `src/main/services/team/index.ts` (export `TeamKanbanManager`) +- `src/main/services/infrastructure/FileWatcher.ts` (teamsWatcher: учитывать `kanban-state.json` как `config` change) +- `src/renderer/store/slices/teamSlice.ts` (action `updateKanban`, refresh после write) +- `src/renderer/components/team/TeamDetailView.tsx` (вставить kanban panel) +- `src/renderer/api/httpClient.ts` (browser-mode заглушка для `updateKanban`) + +--- + +## Порядок работ (runbook) с контрольными точками + +### CP0 — типы компилируются + +1) **Shared types** + - В `src/shared/types/team.ts` добавить kanban-типы и `TeamData.kanbanState`. + - В `src/shared/types/api.ts` добавить `TeamsAPI.updateKanban`. +2) `pnpm typecheck` + +--- + +### CP1 — backend читает/пишет `kanban-state.json` + +3) **Atomic write (минимум)** + - `src/main/services/team/atomicWrite.ts`: + - `atomicWriteAsync(targetPath, data)` через `tmp + rename` + `mkdir({ recursive:true })` + - best-effort `fsync` (`open(tmp, 'r+')` → `sync()`), без hard-fail + - EXDEV fallback (copy+unlink) как safety net + +4) **TeamKanbanManager** + - `getState(teamName)`: + - читает `~/.claude/teams/{teamName}/kanban-state.json` + - ENOENT → default `{ teamName, reviewers: [], tasks: {} }` + - invalid JSON → default + warning (через `TeamData.warnings`) + - `updateTask(teamName, taskId, patch)`: + - `patch: { column: 'review'|'approved' }` → set state + `movedAt = now` + - `patch: { remove: true }` → delete entry + - write через `atomicWriteAsync` + - `garbageCollect(teamName, validTaskIds)`: + - удаляет записи `tasks[id]` которых нет в `validTaskIds` + - dirty-check: писать файл только если реально что-то удалили + +5) **TeamDataService** + - добавляем зависимость `TeamKanbanManager` + - в `getTeamData(teamName)`: + - сначала грузим `tasks` + - затем `kanbanState` + - затем **GC** (по `Set(tasks.map(id))`) + - возвращаем `TeamData` с `kanbanState` + +6) `pnpm typecheck` + +--- + +### CP2 — IPC: updateKanban работает end-to-end + +7) **Guards** + - `validateTaskId()` в `src/main/ipc/guards.ts` (pattern как `validateTeamName()`): + - строка, только цифры, разумный лимит длины (например 1–10) + +8) **IPC + preload** + - `TEAM_UPDATE_KANBAN` в `src/preload/constants/ipcChannels.ts` + - `src/main/ipc/teams.ts`: handler `updateKanban(teamName, taskId, patch)`: + - validateTeamName + validateTaskId + - вызвать `teamDataService.updateKanban(...)` (новый метод) + - вернуть `IpcResult` + - `src/preload/index.ts`: `teams.updateKanban(...)` через `invokeIpcWithResult()` + +9) Быстрая проверка: + - в DevTools: `await window.electronAPI.teams.updateKanban(teamName, taskId, { column: 'review' })` + +10) `pnpm typecheck` + +--- + +### CP3 — UI: KanbanBoard виден и управляем + +11) **UI компоненты** + - `KanbanBoard` получает: + - `tasks: TeamTask[]` + - `kanbanState: KanbanState` + - `onUpdate(taskId, patch)` → вызывает `teams.updateKanban(...)` через store action + - `KanbanTaskCard`: + - показывает subject / owner / status + - показывает `Move to:` select **только для разрешённых переходов**: + - `DONE → REVIEW` (set column review) + - `REVIEW → APPROVED` (set column approved) + - `REVIEW → DONE` (remove state) + - `APPROVED → DONE` (remove state) + - для `TODO`/`IN PROGRESS` — только read-only (двигать нельзя) + - В колонке `REVIEW` рендерит `ReviewBadge` (в итерации 03 всегда “Pending”) + +12) **TeamDetailView layout** + - Фиксируем layout без вариантов (чтобы не расползлось): + - **Left**: `MemberList` + - **Center**: `KanbanBoard` + - **Right**: текущий `TaskList` (из итерации 02) как “сырой список” задач + - Обязательные состояния: loading/error/empty/data. + - UI инвариант: kanban всегда строится из `TeamData.tasks` + `TeamData.kanbanState` (никаких дополнительных fetch). + +13) **Live refresh** + - `FileWatcher`: + - в `teamsWatcher` добавить обработку `kanban-state.json` как `type: 'config'` (detail: `kanban-state.json`) + - это гарантирует, что существующий renderer coalesce обновит team data + +14) `pnpm test` + +15) Ручная проверка + - Открыть Team tab → kanban виден. + - Взять completed задачу → `Move to REVIEW` → появляется в REVIEW и создаётся/обновляется `kanban-state.json`. + - `Move back` → запись удаляется, задача возвращается в DONE. + - Отредактировать `kanban-state.json` вручную → UI обновится. + +--- + +## Риски и митигации + +- **GC удалит валидные записи**: запрещено делать GC до чтения tasks. В `TeamDataService` фиксируем порядок (tasks → kanban → GC). +- **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink. +- **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен). +- **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах. + diff --git a/docs/iterations/iteration-04-messaging-review.md b/docs/iterations/iteration-04-messaging-review.md new file mode 100644 index 00000000..37a4763d --- /dev/null +++ b/docs/iterations/iteration-04-messaging-review.md @@ -0,0 +1,296 @@ +# Итерация 04 — Messaging + Review (Inbox + ReviewDialog) + +Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**. + +Основание: +- `docs/team-management/implementation.md` (**Iteration 4: Messaging + Review**) +- `docs/team-management/research-inbox.md` (формат, race conditions, messageId verify) +- `docs/team-management/research-messaging.md` (почему inbox, ограничения доставки) +- `docs/team-management/kanban-design.md` (review flow, allowed transitions) +- `docs/team-management/research-tasks.md` (безопасная запись status при Fix) + +--- + +## Цель итерации + +- Во вкладке Team появляется **ActivityTimeline** (история сообщений) и **MessageComposer** (отправка DM). +- Реализован review-flow: + - **Request Review**: перевод задачи в `REVIEW` + (если есть reviewer) уведомление в его inbox. + - **Approve**: перевод задачи в `APPROVED`. + - **Request Changes**: опциональный комментарий → задача возвращается исполнителю: + - kanban-state запись удаляется (задача вернётся в `IN PROGRESS` по `task.status`) + - `task.status` становится `in_progress` (atomic write + verify) + - отправляется сообщение owner’у с описанием правок +- Live refresh: изменения inbox/task/kanban отражаются в UI (уже существующий `team:change` + coalesce). + +--- + +## Не-цели (строго вне scope) + +- Hard interrupt (прерывание mid-turn) — ограничение платформы, Phase 2. +- Архивация inbox / очистка истории / JSONL формат — Phase 2. +- Полноценный drag-and-drop для review — Phase 2 (пока click-to-move/кнопки). +- Round-robin балансировка reviewer’ов и reviewHistory — Phase 2. +- Любые write-path, связанные с созданием задач и `.highwatermark`. + +--- + +## Контракт итерации (Main → Preload → Renderer) + +### Shared types (`src/shared/types/team.ts`) + +Добавляем/расширяем: + +- `InboxMessage` (как в `research-inbox.md`): + - `from: string` + - `text: string` (plain text или JSON-строка) + - `timestamp: string` (ISO) + - `read: boolean` + - `summary?: string` + - `color?: string` + - `messageId?: string` (в наших исходящих обязателен) +- `SendMessageRequest`: + - `member: string` + - `text: string` + - `summary?: string` + - `from?: 'user' | string` (по умолчанию `'user'`) +- `SendMessageResult`: + - `deliveredToInbox: boolean` (означает “записано в файл и verify прошёл”) + - `messageId: string` + +Расширяем `TeamData`: + +- `messages: InboxMessage[]` (агрегированные по всем inbox, отсортированные по `timestamp`) + +Расширяем `ResolvedTeamMember`: + +- `status: 'active' | 'idle' | 'terminated' | 'unknown'` +- `lastActiveAt: string | null` +- `messageCount: number` +- `color?: string` + +Правила статуса (из `docs/team-management/README.md`): +- `ACTIVE`: last activity < 5 минут +- `IDLE`: last activity ≥ 5 минут +- `TERMINATED`: получен shutdown-event с approve=true (в доках встречаются разные названия, поэтому считаем terminated если structured `text` после JSON.parse имеет: + - `type === 'shutdown_response' && approve === true`, **или** + - `type === 'shutdown_approved'`, **или** + - `type === 'shutdown_response' && approved === true` +- если данных нет → `unknown` + +### IPC каналы + +В `src/preload/constants/ipcChannels.ts` добавляем: + +- `TEAM_SEND_MESSAGE = 'team:sendMessage'` +- `TEAM_REQUEST_REVIEW = 'team:requestReview'` +- `TEAM_UPDATE_KANBAN` уже существует с итерации 03 и **расширяется** (см. ниже) для `request_changes`. + +Важно: в v7-плане перечислены именно `team:sendMessage`, `team:requestReview`, `team:updateKanban`. В этой итерации не вводим новые каналы сверх этого набора. + +### TeamsAPI (`src/shared/types/api.ts`) + +Расширяем: + +- `sendMessage(teamName: string, req: SendMessageRequest): Promise` +- `requestReview(teamName: string, taskId: string): Promise` +- `updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise` (из итерации 03, расширяем patch) + +#### UpdateKanbanPatch — расширение в итерации 04 (без двусмысленностей) + +К существующим операциям добавляем: + +- `{ op: 'request_changes'; comment?: string }` + +Семантика `request_changes` (всё внутри main, как единая операция): +1) Удалить kanban-state запись для taskId (remove) +2) Обновить task файл: `status = 'in_progress'` (atomic write + verify) +3) Отправить сообщение owner’у задачи с комментарием (если owner отсутствует → ошибка) + +--- + +## Definition of Done (DoD) + +- **Messaging UI** + - В Team tab есть ActivityTimeline (список сообщений) и MessageComposer. + - Можно выбрать участника и отправить сообщение → появляется “Delivered” (или ошибка). + - Сообщение записывается в `~/.claude/teams/{team}/inboxes/{member}.json` с `messageId`. +- **Review UI** + - На задачах в `DONE` есть действие “Request Review”. + - В `REVIEW` есть кнопки “Approve” и “Request Changes”. + - “Request Changes” открывает диалог с опциональным комментарием. +- **Backend safety** + - Inbox write: atomic write + verify `messageId` + retry/backoff (по плану ниже). + - Task status write (Fix): atomic write + verify `status` + warning в UI при конфликте. +- **Качество** + - `pnpm typecheck` проходит. + - `pnpm test` проходит. + +--- + +## Выходные изменения (файлы) + +### Новые файлы + +- `src/main/services/team/TeamInboxWriter.ts` (write-path: atomic + verify + retry + lock) +- `src/main/services/team/TeamTaskWriter.ts` (минимум: `updateStatus(teamName, taskId, status)`), чтобы не смешивать чтение и запись +- `src/renderer/components/team/activity/ActivityTimeline.tsx` +- `src/renderer/components/team/activity/ActivityItem.tsx` +- `src/renderer/components/team/activity/MessageComposer.tsx` +- `src/renderer/components/team/dialogs/ReviewDialog.tsx` + +### Изменяемые файлы + +- `src/shared/types/team.ts` (InboxMessage, SendMessage*, member status, `TeamData.messages`) +- `src/shared/types/api.ts` (TeamsAPI methods) +- `src/preload/constants/ipcChannels.ts` (+ 3 канала) +- `src/preload/index.ts` (bridge для sendMessage/requestReview/updateKanban) +- `src/main/ipc/guards.ts` (+ `validateMemberName()`, `validateFromField()` если потребуется) +- `src/main/ipc/teams.ts` (+ handlers) +- `src/main/services/team/TeamInboxReader.ts` (расширить: `getMessagesFor()`, `getMessages()`) +- `src/main/services/team/TeamMemberResolver.ts` (status/messageCount/color/lastActiveAt + terminated detection) +- `src/main/services/team/TeamDataService.ts` (подтянуть messages + review methods; опираться на kanban из итерации 03; в requestReview — sendMessage первому reviewer при наличии) +- `src/main/services/team/index.ts` (exports) +- `src/renderer/store/slices/teamSlice.ts` (actions + sending states) +- `src/renderer/components/team/TeamDetailView.tsx` (layout: members + kanban + activity) +- `src/main/services/infrastructure/FileWatcher.ts` (ничего нового: inbox/task/kanban уже должны попадать в `team-change`) + +--- + +## Порядок работ (runbook) с контрольными точками + +### CP0 — типы компилируются + +1) Обновить `src/shared/types/team.ts` и `src/shared/types/api.ts` (контракты выше). +2) `pnpm typecheck` + +--- + +### CP1 — Inbox read/write в main готов (без UI) + +3) **Inbox read** + - `listInboxNames(teamName)` (уже есть/или оставить) — список `*.json` без расширения + - `getMessagesFor(teamName, member)`: + - ENOENT → `[]` + - invalid JSON → `[]` + warning + - `getMessages(teamName)`: + - читает все inbox `*.json` (параллельно, best-effort) + - merge в один массив + - сортировка по `timestamp` (desc) + +4) **Inbox write: atomic + verify + retry** + +Реализация должна следовать `research-inbox.md`: + +- Каждое сообщение, которое пишет приложение, включает: + - `messageId = crypto.randomUUID()` + - `from = 'user'` (по умолчанию) + - `read = false` + - `timestamp = new Date().toISOString()` + +Алгоритм (в main): + +``` +read inbox JSON array (если нет → []) +append message +atomicWriteAsync(tmp + rename) +read back +verify messageId exists +если не найден → retry (до 3) с backoff 10/20/40ms +``` + +Параллелизм: +- внутри процесса сериализуем записи в один `inboxPath` через `withInboxLock(inboxPath, fn)`, + чтобы два IPC вызова не затёрли друг друга. + +Fallback по `from`: +- делаем это **не магией**, а контрактом API: + - `SendMessageRequest.from` опционален, но если задан — валидируем (`validateFromField` или reuse `validateMemberName`) + - UI в этой итерации **не обязано** показывать выбор from; по умолчанию отправляем `from: 'user'` + - для отладки можно вызывать через DevTools `sendMessage(..., { from:'debug-user', ... })` + +5) `pnpm test` (если добавлялись новые unit-тесты) и `pnpm typecheck` + +--- + +### CP2 — Review actions в main + IPC + +6) **Task status write (Fix)** + +Новый `TeamTaskWriter.updateStatus(teamName, taskId, status)`: +- читает `~/.claude/tasks/{teamName}/{taskId}.json` +- меняет только `status` +- пишет через `atomicWriteAsync` +- перечитывает и проверяет, что `status` равен нужному +- при конфликте (агент перезаписал) → throw, чтобы UI показал warning + +7) **TeamDataService** +- `getTeamData(teamName)` теперь возвращает также `messages` +- `requestReview(teamName, taskId)`: + - `updateKanban(taskId, { op:'set_column', column:'review' })` (reviewStatus: pending) + - выбрать reviewer: + - если `kanbanState.reviewers[]` не пуст → первый элемент (round-robin — Phase 2) + - иначе reviewer отсутствует (manual review) + - если reviewer выбран → `sendMessage(reviewer, "...")` + - `approve` делаем напрямую через `updateKanban(taskId, { op:'set_column', column:'approved' })` + - `request_changes` делаем через `updateKanban(taskId, { op:'request_changes', comment })` (см. семантику patch выше) + +8) **IPC** +- `TEAM_SEND_MESSAGE`: принимает `teamName`, `SendMessageRequest` +- `TEAM_REQUEST_REVIEW`: принимает `teamName`, `taskId` +- `TEAM_UPDATE_KANBAN`: принимает `teamName`, `taskId`, `UpdateKanbanPatch` (расширенный) + +Guards: +- `validateMemberName()` обязателен (защита от path traversal, member используется в пути inbox файла). +- `validateTaskId()` уже есть с итерации 03. + +9) `pnpm typecheck` + +--- + +### CP3 — UI: ActivityTimeline + MessageComposer + ReviewDialog + +10) **Layout** +- Team tab становится 3-панельным: + - слева Members + - центр Kanban + - справа Activity (messages + composer) + +Чтобы не потерять доступ к “сырому списку задач”, фиксируем простой UI-компромисс: +- правый panel становится **табами**: `Activity` и `Tasks` +- по умолчанию открываем `Activity`, но `Tasks` остаётся доступным (и переиспользует текущий `TaskList`) + +11) **ActivityTimeline** +- Рендерит `TeamData.messages` (последние N, например 200) с: + - цветной dot (если есть `color`) + - `from`, `summary` (или короткий preview `text`) + - timestamp (relative time) + +12) **MessageComposer** +- select получателя (из `members`) +- textarea (минимум 1–2 строки) +- send → вызывает store action → IPC `sendMessage` +- состояния: + - sending / delivered / error + +13) **ReviewDialog** +- открывается при “Request Changes” +- comment optional +- submit → `updateKanban(..., { op:'request_changes', comment })` + +14) Ручная проверка +- Отправить сообщение → файл inbox обновился, message появился в ActivityTimeline после refresh. +- Нажать Request Review → задача в REVIEW, при наличии reviewer’а в `kanban-state.reviewers` ему ушло сообщение. +- Approve → задача в APPROVED. +- Request Changes → задача ушла исполнителю (status=in_progress) + отправлено сообщение owner’у. + +15) `pnpm test` + +--- + +## Риски и митигации + +- **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`. +- **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail. +- **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05). + diff --git a/docs/iterations/iteration-05-testing-polish.md b/docs/iterations/iteration-05-testing-polish.md new file mode 100644 index 00000000..750bb42a --- /dev/null +++ b/docs/iterations/iteration-05-testing-polish.md @@ -0,0 +1,207 @@ +# Итерация 05 — Testing + Polish (production-ready) + +Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks. + +Основание: +- `docs/team-management/implementation.md` (**Iteration 5: Testing + Polish**) +- `docs/team-management/research-inbox.md` (race + verify, messageId) +- `docs/team-management/research-tasks.md` (task write verify) +- `docs/team-management/kanban-design.md` (GC order, mapping) + +--- + +## Цель итерации + +- Добавить **тесты** на: + - kanban-state чтение/запись/GC + - inbox read/write (atomic + verify + retry + lock) + - task status write verify (request changes) + - IPC guards и handlers (валидаторы + happy/err paths) + - store coalesce/throttle на `team:change` +- Улучшить UX: + - ясные empty/error/loading состояния для всех team-panels + - аккуратные сообщения об ошибках write-path (inbox/task) + - ограничение и оптимизация рендера больших списков +- Подготовить основу для Phase 2 (D&D, archive, round-robin), не реализуя их. + +--- + +## Не-цели + +- E2E/UI automation (можно позже отдельным треком). +- Полный performance-профайлинг приложения. +- Редизайн UI — только точечный polish. + +--- + +## Definition of Done (DoD) + +- **Тесты** + - Добавлены новые тесты (см. ниже), все проходят локально. + - `pnpm test` зелёный. + - `pnpm typecheck` зелёный. +- **UX** + - Нет “пустых серых экранов”: каждый экран имеет понятное состояние. + - Ошибки write-path показываются пользователю понятным текстом. +- **Надёжность** + - Все write-path используют atomic write. + - Верификация после записи включена для inbox и task status. + +--- + +## Тестовая стратегия (конкретно что проверяем) + +### 1) TeamKanbanManager + +- `getState()`: + - ENOENT → default state + - corrupted JSON → default + warning (если warnings протаскиваются) +- `updateTask()`: + - set column → появляется запись с `movedAt` + - remove → запись исчезает +- `garbageCollect()`: + - удаляет только невалидные taskId + - dirty-check: не пишет файл, если удалять нечего +- **GC order**: + - тест на `TeamDataService.getTeamData()` что GC вызывается **после** чтения tasks (контракт из `kanban-design.md`) + +### 2) Inbox write-path + +- `sendMessage()`: + - пишет `messageId`, `timestamp`, `read:false` + - после записи verify находит `messageId` +- Race simulation (упрощённо): + - смоделировать “потерю” (подменой read-back) → retry с backoff вызывается нужное число раз и в итоге error/ok как ожидается +- `withInboxLock()`: + - два параллельных send в один inbox → оба сообщения сохраняются + +### 3) Task status write-path + +- `updateStatus()`: + - меняет только `status` + - verify после write подтверждает изменение + - конфликт (подменить файл после write) → ошибка, UI сможет показать warning + +### 4) IPC / Guards + +- `validateTeamName`, `validateTaskId`, `validateMemberName`: + - reject path traversal (`../`, слеши, пробелы, пустые) + - accept валидные имена +- Handlers: + - invalid args → `IpcResult.success=false` + - happy path → `success=true` + +### 5) Renderer store + +- Coalesce/throttle на `team:change`: + - серия событий <300ms → один refresh + - если team не виден → не делает лишние `getData` (но обновляет list если это заложено) + +--- + +## Рекомендуемая структура тестов (адаптировать под текущее дерево) + +``` +test/ +├── main/ +│ ├── services/team/ +│ │ ├── TeamKanbanManager.test.ts +│ │ ├── TeamInboxReader.test.ts # read (list + merge + sort) +│ │ ├── TeamInboxWriter.test.ts # sendMessage (atomic + verify + retry + lock) +│ │ ├── TeamTaskWriter.test.ts +│ │ └── TeamDataService.test.ts # orchestration + GC order +│ └── ipc/ +│ ├── guards.test.ts # расширить (team/task/member/from) +│ └── teams.test.ts # новые каналы/handlers +├── renderer/ +│ └── store/ +│ └── teamSlice.test.ts # refresh, send, review actions, throttle +└── fixtures/team/ + ├── teams/ + │ └── my-team/ + │ ├── config.json + │ ├── kanban-state.json + │ └── inboxes/ + │ ├── alice.json + │ └── reviewer.json + └── tasks/ + └── my-team/ + ├── 12.json + ├── 13.json + ├── .lock + └── .highwatermark +``` + +Важно: +- `.lock`/`.highwatermark` должны присутствовать в fixture, чтобы гарантировать “пропуск мусорных файлов”. +- Не используем реальный `~/.claude` в тестах. + +--- + +## UX/Polish checklist + +- **Empty states** + - Нет tasks → “Нет задач в этой команде” + - Нет сообщений → “Нет сообщений” + подсказка “Отправь сообщение” + - Нет reviewers → в REVIEW показывать “Manual review” (без авто-assign) +- **Error states** + - Inbox write verify failed → “Сообщение записано, но не подтверждено (race). Попробуй отправить ещё раз.” + - Task status verify failed → “Не удалось обновить статус задачи (возможен конфликт с агентом).” +- **Loading** + - Skeleton для TeamDetailView (3 панели) +- **Performance** + - ActivityTimeline: рендерим только последние N (например 200), кнопка “Показать ещё” (не обязателен infinite scroll) + - Если tasks > 200: предусмотреть простую фильтрацию по owner/status (минимально) +- **Accessibility** + - Кнопки имеют понятные `aria-label` + - Навигация по клавиатуре: focus ring виден + +--- + +## Порядок работ (runbook) + +### CP0 — стабилизируем контракты + +- Пройтись по типам `TeamData`, `InboxMessage`, kanban types. +- `pnpm typecheck` + +### CP1 — тесты main/services + +- Добавить fixtures. +- Написать тесты для kanban/inbox/task writer/service orchestration. +- `pnpm test` (обязательно запускать сразу после добавления/правки тестов) + +### CP2 — тесты IPC/guards + +- Расширить `guards.test.ts` +- Добавить `teams.test.ts` для новых handler’ов +- `pnpm test` + +### CP3 — тесты store + polish + +- teamSlice tests на coalesce и actions. +- UI polish по чеклисту. +- `pnpm typecheck` + `pnpm test` + +--- + +## Риски и митигации + +- **Тесты станут “флейковыми”** из-за таймеров/throttle: + - использовать fake timers и явный flush. +- **Слишком много моков Electron**: + - держать единый helper mock `electronAPI` в `test/mocks/`. +- **Большие списки сообщений**: + - ограничение N + простой “show more” снимают риск O(n) перерендера. + +--- + +## Минимальный чеклист регрессии (ручной) + +1) Teams → открыть команду → kanban/задачи/участники видны +2) Move DONE → REVIEW → состояние сохранилось (после перезапуска app остаётся) +3) Отправить сообщение → delivered + появилось в Activity +4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло +5) Request Changes → task.status стал `in_progress` + owner получил сообщение +6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с + diff --git a/docs/iterations/iteration-06-team-provisioning.md b/docs/iterations/iteration-06-team-provisioning.md new file mode 100644 index 00000000..0c7f38a4 --- /dev/null +++ b/docs/iterations/iteration-06-team-provisioning.md @@ -0,0 +1,361 @@ +# Итерация 06 — Team Provisioning (Create Team из UI через CLI + File Monitoring) + +Эта итерация добавляет **создание команды из UI**: пользователь задаёт параметры команды, приложение запускает CLI-процесс, затем **верифицирует результат через файлы** в `~/.claude/teams/**` и сообщает прогресс в UI. + +Основание: +- `docs/team-management/research-cli-orchestration.md` (v2) — итоговая схема spawn + monitoring, PATH на macOS +- `docs/team-management/README.md` — важные ограничения формата (`config.json` members неполный) +- Текущая реализация Team Management (итерации 01–04): `team:list`, `team:getData`, `team:change`, `team:sendMessage`, `team:requestReview`, `team:updateKanban` + +--- + +## Цель итерации + +- В `Teams` появляется кнопка **Create Team**. +- Открывается диалог создания команды: + - `teamName` (канонический ID = имя директории) + - `displayName` (пишется в `config.json.name`, по умолчанию = `teamName`) + - `description` (опционально) + - `members` (минимум 1 имя; роли опциональны — только для prompt) + - `cwd` (рабочая директория проекта; выбирается через нативный folder picker) +- По нажатию Create: + - main process валидирует входные данные и **находит путь к CLI бинарнику** (macOS PATH fix). + - запускает CLI в **одноходовом** режиме (`-p`) с env `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. + - UI получает **стрим прогресса**: `validating → spawning → monitoring → verifying → ready/failed/cancelled`. +- Команда считается созданной (готовой к просмотру в UI), когда: + - `~/.claude/teams/{teamName}/config.json` существует и является валидным JSON, содержащим `name: string`. + - `team:list` начинает возвращать эту команду. + +--- + +## Не-цели (строго вне scope) + +- Создание/управление задачами через provisioning (TaskCreate, seed tasks) — только в prompt как best-effort, без жёсткой верификации. +- Гарантия, что teammates “точно запущены” и отвечают — MVP provisioning считает успехом создание команды (config), остальное — последующие итерации/полировка. +- Поддержка provisioning в SSH-context (remote) — **только local context**. +- Парсинг stdout CLI (`stream-json`, NDJSON) — **не делаем**, чтобы исключить backpressure/encoding/64KB буферы. +- Авто-удаление существующих директорий команды. +- Интеграция `leadSessionId` с session viewer (это отдельная задача). + +--- + +## Допущения и ограничения (явно) + +- Provisioning работает **только в local context**. +- CLI provisioning не гарантирует, что все участники сразу “живые” и отвечают. MVP-критерий успеха — **созданная команда** (валидный `config.json`). +- `config.json.members` **не является** источником полного списка участников. Полный roster будет восстанавливаться как и сейчас: `union(config members + inbox filenames + task owners)`. +- Любые частичные артефакты на диске после `cancelled/failed` **не удаляются автоматически**. + +--- + +## Контракт итерации (Main → Preload → Renderer) + +### IPC каналы (flat `export const` в `src/preload/constants/ipcChannels.ts`) + +- `TEAM_CREATE = 'team:create'` +- `TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'` +- `TEAM_PROVISIONING_PROGRESS = 'team:provisioningProgress'` (event main → renderer) + +### Shared types (`src/shared/types/team.ts`) + +Добавляем: + +```ts +export type TeamProvisioningState = + | 'idle' + | 'validating' + | 'spawning' + | 'monitoring' + | 'verifying' + | 'ready' + | 'failed' + | 'cancelled'; + +export interface TeamProvisioningMemberInput { + name: string; + role?: string; +} + +export interface TeamCreateRequest { + teamName: string; // canonical ID (directory name) + displayName?: string; // config.json.name (default = teamName) + description?: string; + members: TeamProvisioningMemberInput[]; + cwd: string; // project working directory +} + +export interface TeamCreateResponse { + runId: string; +} + +export interface TeamProvisioningProgress { + runId: string; + teamName: string; + state: Exclude; + message: string; + startedAt: string; // ISO + updatedAt: string; // ISO + pid?: number; + error?: string; + warnings?: string[]; +} +``` + +### Семантика IPC (без двусмысленностей) + +- `team:create(request)`: + - валидирует `teamName`, `cwd`, `members` (см. правила валидации ниже) + - если команда уже существует (`~/.claude/teams/{teamName}/config.json` существует) → `IpcResult` error (“Team already exists”) + - если provisioning уже запущен для этого `teamName` → `IpcResult` error (“Provisioning already running”) + - иначе стартует provisioning и возвращает `{ runId }` +- `team:provisioningStatus(runId)`: + - возвращает текущий snapshot прогресса (или ошибку “Unknown runId”) +- `team:provisioningProgress` (event): + - отправляется при каждом переходе state machine (и при важных milestones внутри шага) + - payload всегда включает `runId`, чтобы UI мог безопасно фильтровать + +### Правила валидации (без двусмысленностей) + +- `teamName`: + - строка, trim не пустой + - только `[a-z0-9-]`, длина 1..64 + - не может начинаться/заканчиваться на `-` + - не может содержать `..`, `/`, `\`, пробелы +- `cwd`: + - абсолютный путь + - должен существовать и быть директорией +- `members[].name`: + - строка, trim не пустой + - валидируем тем же правилом, что и для inbox member name (чтобы любые best-effort проверки файлов не открывали путь traversal) + - дубликаты по имени запрещены + +--- + +## Архитектура provisioning (main) + +### State machine + +``` +idle + → validating + → spawning + → monitoring + → verifying + → ready + ↘ failed + ↘ cancelled +``` + +### Binary resolution (macOS PATH проблема) + +Добавляем `ClaudeBinaryResolver` (main): + +- сперва пробуем `claude` в текущем PATH (на случай запуска из терминала) +- затем common paths (`/usr/local/bin/claude`, `/opt/homebrew/bin/claude`, `~/.npm-global/bin/claude`, nvm paths, Windows `.cmd`) +- если не найдено → понятная ошибка для UI (“Claude CLI не найден; установите или укажите путь”) +- **запрещено** использовать `shell: true` для любых вызовов + +### Spawn contract (одноходовый, без stdout) + +Запуск: + +- args: `['-p', prompt, '--output-format', 'text']` +- env: `...process.env` + `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` +- `cwd = request.cwd` +- `stdio = ['ignore', 'ignore', 'pipe']` + - stdin ignored (неинтерактивно) + - stdout ignored (не читаем и не рискуем deadlock’ом) + - stderr piped (только для диагностики) + +**Важно (без двусмысленностей): stderr pipe обязателен к drain’у.** + +- мы **всегда** читаем `child.stderr.on('data')` и: + - складываем в ring-buffer (например, последние 64KB), чтобы не держать бесконечный лог в памяти + - используем для `failed.error` (обрезанный, безопасный текст) + +### Таймауты и зависания (без двусмысленностей) + +- Если процесс не завершился за `T_TOTAL` (например 2 минуты) → переводим в `failed` с ошибкой “Timed out waiting for CLI”. +- В тексте ошибки обязателен хинт: + - CLI может требовать интерактивную авторизацию/онбординг + - нужно запустить `claude` один раз из терминала и завершить онбординг, затем повторить provisioning + +### Cancel semantics (без двусмысленностей) + +- Cancel доступен только в состояниях: `spawning | monitoring | verifying`. +- При cancel: + - если child process ещё существует → посылаем завершение (best-effort) + - state становится `cancelled` + - любые созданные файлы/директории **не удаляем** + +### Monitoring + verifying (через файлы) + +Monitoring триггерится после `exit code === 0` (или сразу, если config появляется раньше). + +Проверки: +- `config.json` существует по пути `~/.claude/teams/{teamName}/config.json` +- JSON валиден +- `config.name` — непустая строка + +Оговорка: `config.json.members` **не является** источником полного состава команды (см. `docs/team-management/README.md`). Поэтому эта итерация **не требует** полного roster в config как критерий успеха. + +Дополнительная best-effort верификация (не блокирует ready): +- если prompt включает “bootstrap DM” каждому участнику, то ожидаем появление `inboxes/{member}.json` +- при таймауте: `ready` + `warnings: ['Some inboxes not created yet']` + +### Разрешение конфликтов сигналов (без двусмысленностей) + +- Если `config.json` стал валидным и прошёл verify, но процесс CLI завершился с non-zero: + - итоговое состояние: `ready` + - `warnings`: добавить строку вида “CLI exited with code X after config was created” +- Если процесс CLI завершился `0`, но `config.json` не появился/невалиден до таймаута verify: + - итоговое состояние: `failed` + - error: “TeamCreate did not produce a valid config.json” + +--- + +## Изменения в UI (renderer) + +### Точка входа + +- В `TeamListView` добавляем кнопку **Create Team**. +- В browser-mode и SSH-context: + - кнопка disabled + подпись “Доступно только в local Electron-режиме”. + +### CreateTeamDialog (минимальный UX) + +- Валидация на клиенте: + - `teamName`: kebab-case `[a-z0-9-]` (сообщение об ошибке рядом с полем) + - `members`: минимум 1; имена не пустые + - `cwd`: обязателен +- После старта provisioning: + - показываем шаги прогресса + - показываем кнопку Cancel (если процесс ещё в `spawning/monitoring/verifying`) + - при `ready`: кнопка “Open Team” (открывает team tab) + - при `failed`: показываем error + “Retry” (повторно вызывает create; если команда уже создана — предлагаем открыть) + +### Store contract (Zustand) + +Добавляем в `teamSlice`: +- `provisioningRuns: Record` +- `activeProvisioningRunId: string | null` +- actions: + - `createTeam(req): Promise` + - `cancelProvisioning(runId): Promise` (если делаем cancel в этой итерации) + - subscribe/unsubscribe на `team:provisioningProgress` + +--- + +## Выходные изменения (файлы) + +### Новые файлы (ожидаемо) + +- `src/main/services/team/ClaudeBinaryResolver.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/shared/types` (расширение `team.ts` provisioning-типами) +- `src/renderer/components/team/dialogs/CreateTeamDialog.tsx` + +### Изменяемые файлы (ожидаемо) + +- `src/preload/constants/ipcChannels.ts` (новые каналы) +- `src/shared/types/api.ts` (TeamsAPI: `createTeam`, `getProvisioningStatus`, `onProvisioningProgress`) +- `src/preload/index.ts` (bridge) +- `src/main/ipc/teams.ts` (+ handlers) +- `src/main/ipc/guards.ts` (валидация `cwd` и members, если нужно) +- `src/renderer/components/team/TeamListView.tsx` (кнопка + диалог) +- `src/renderer/store/slices/teamSlice.ts` (state + actions) + +--- + +## Порядок работ (runbook) с контрольными точками + +### CP0 — типы и IPC компилируются + +1) Расширить shared types (provisioning contract). +2) Добавить IPC channels constants. +3) `pnpm typecheck` + +### CP1 — main provisioning skeleton (без UI) + +4) `ClaudeBinaryResolver` (без `shell: true`). +5) `TeamProvisioningService`: + - lock по `teamName` + - state machine + - spawn contract (`stdio` + stderr drain + timeout) + - monitoring/verifying по `config.json` +6) IPC handlers `team:create`, `team:provisioningStatus`, progress event. +7) `pnpm test` + `pnpm typecheck` + +### CP2 — preload bridge + renderer store + +8) `TeamsAPI` расширение + preload bridge. +9) `teamSlice` actions + подписка на progress event. +10) `pnpm typecheck` + +### CP3 — UI dialog + +11) `CreateTeamDialog` + интеграция в `TeamListView`. +12) Ручная проверка (см. ниже). +13) `pnpm test` + `pnpm typecheck` + +--- + +## Manual regression checklist (обязательно) + +1) Local mode: Create Team → прогресс до `ready`, команда появляется в списке. +2) Ошибка “CLI не найден” на macOS Finder-запуске воспроизводится → UI показывает понятное сообщение. +3) Cancel во время `spawning/monitoring`: + - UI показывает `cancelled` + - процесс не остаётся “висячим” +4) Повторный create с тем же `teamName`, когда команда уже существует → понятная ошибка без перезаписи. +5) После `ready` можно открыть Team tab и увидеть данные (пусть даже roster заполнится постепенно). + +--- + +## Appendix A — One-shot prompt template (одноходовый bootstrap) + +Ниже шаблон prompt, который должен отработать в одном turn в режиме `-p`. + +**Переменные (подставляет приложение):** +- `{teamName}` — directory name +- `{displayName}` — UI name +- `{description}` — описание (может быть пустым) +- `{members}` — список участников (строки), где каждый элемент: + - `name` (валидирован) + - `role` (опционально) + +**Шаблон:** + +``` +You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. + +Goal: Provision a Claude Code agent team. + +Constraints: +- Use ONLY these tools: TeamCreate, SendMessage, TaskCreate. +- Do not use Read/Edit/Bash. +- Do not output large logs. Keep assistant text minimal. + +Steps (must be executed in this order): +1) Call TeamCreate to create team "{teamName}" with: + - display name: "{displayName}" + - description: "{description}" + - members: the list below (include roles if present) + +2) For each member in the list, call SendMessage to send a short bootstrap DM: + - summary: "Bootstrap" + - text: "Team \"{displayName}\" is ready. Reply with 'OK' when you are available." + +3) (Best-effort) Create 1 initial task via TaskCreate: + - subject: "Bootstrap check" + - description: "Confirm team provisioning succeeded. Each member replied OK." + - owner: leave empty (unassigned) + +Members: +{members} +``` + +Примечания: +- Bootstrap DM нужен, чтобы с высокой вероятностью создались `inboxes/{member}.json`. +- TaskCreate — best-effort; отсутствие задач НЕ блокирует `ready`. + diff --git a/docs/team-management/research-cli-orchestration.md b/docs/team-management/research-cli-orchestration.md new file mode 100644 index 00000000..1131495e --- /dev/null +++ b/docs/team-management/research-cli-orchestration.md @@ -0,0 +1,493 @@ +# Research: CLI Orchestration for Team Provisioning + +## Status: RESEARCH COMPLETE (v2) + +Результаты исследования 10 агентов (3 + 4 + 3) по запуску Claude CLI для создания команд. +Раунд 3 — верификация и финальная архитектура. + +--- + +## 1. Варианты запуска Claude CLI + +### 1.1 Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) + +**Пакет**: `@anthropic-ai/claude-agent-sdk` v0.2.44 (npm) +**Repo**: `github.com/anthropics/claude-agent-sdk-typescript` +**Статус**: Official, maintained by Anthropic, pre-1.0 + +#### API + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +for await (const msg of query({ + prompt: "Create a team to work on X", + options: { + allowedTools: ["TeamCreate", "TaskCreate", "SendMessage", "Read", "Edit", "Bash"], + model: "opus", + maxTurns: 250, + cwd: "/path/to/project", + permissionMode: "acceptEdits", // или "bypassPermissions" + } +})) { + console.log(msg); // SDKMessage — structured +} +``` + +#### Характеристики + +| Параметр | Значение | +|----------|----------| +| Startup overhead | ~12 сек per query() call | +| Hot process reuse | НЕТ (issue #34 в SDK repo) | +| Internal mechanism | Спавнит Claude Code CLI как subprocess | +| Team tools | Доступны с `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` | +| Streaming | AsyncGenerator | +| Multi-turn | Stateless — каждый query() = новая сессия | +| Node.js | 18+ | +| Electron compatible | Да (main process only) | + +#### V2 Preview (simpler API) + +```typescript +import { unstable_v2_prompt } from "@anthropic-ai/claude-agent-sdk"; +const result = await unstable_v2_prompt("What is 2 + 2?"); +``` + +#### Проблемы + +- **12 сек startup** — непригоден для real-time UX +- **No hot reuse** — каждый вызов = new process (issue #34) +- **Pre-1.0** — API может измениться +- **Team features experimental** — документированные issues: + - Session resumption breaks team state + - Task status can lag + - Shutdown slow + - One team per session + +### 1.2 CLI с `--output-format stream-json` + +**Флаги**: + +| Флаг | Опции | Описание | +|------|-------|----------| +| `--output-format` | `text`, `json`, `stream-json` | Формат stdout | +| `--input-format` | `text`, `stream-json` | Формат stdin | +| `-p "prompt"` | string | Non-interactive (print mode) | +| `--verbose` | flag | Подробный вывод | + +#### Stream-JSON Protocol (NDJSON) + +Каждая строка — полный JSON object: + +```json +{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"} +{"type":"message","role":"assistant","content":[{"type":"text","text":"Analyzing..."}]} +{"type":"tool_use","name":"TeamCreate","input":{"team_name":"my-team","description":"..."}} +{"type":"tool_result","output":"Team created successfully"} +{"type":"result","status":"success","duration_ms":1234} +``` + +#### Можно детектить tool_use events + +```bash +claude -p "Create a team" --output-format stream-json | \ + jq 'select(.type == "tool_use" and .name == "TeamCreate")' +``` + +#### Проблемы + +- **Баг #5034**: `--input-format stream-json` дублирует entries в session .jsonl при multi-turn +- **Stateful parsing**: tool_result не имеет поля `previous_event` → нужна корреляция вручную +- **stdio buffer**: 64KB platform limit → deadlock если не drain'ить +- **Encoding**: UTF-8 multi-byte может порваться на chunk boundary +- **Backpressure**: если не читаем stdout → subprocess зависает + +### 1.3 CLI с file-based monitoring (без парсинга stdout) + +```bash +spawn(claudePath, [prompt], { + stdio: ['ignore', 'ignore', 'pipe'], # stdout ИГНОРИРУЕМ, stderr для ошибок +}) +``` + +Мониторинг через FileWatcher: + +``` +~/.claude/teams/{name}/config.json → team created/updated +~/.claude/teams/{name}/inboxes/*.json → messages +~/.claude/tasks/{name}/*.json → tasks +~/.claude/projects/{path}/{id}.jsonl → session data (already parsed by app) +``` + +#### Преимущества + +- JSONL = stable API contract (не меняется между версиями CLI) +- FileWatcher уже есть в проекте (retry, SSH polling, debounce) +- Crash-safe: файлы на диске сохраняются +- No buffer overflow: читаем файлы, не pipes +- Масштабируется: один FileWatcher для N агентов + +--- + +## 2. Проблема PATH на macOS + +### Суть проблемы + +Когда Electron запускается из macOS Finder (не из терминала), `process.env.PATH` минимальный: + +``` +/usr/bin:/bin:/usr/sbin:/sbin +``` + +**Отсутствуют**: `/usr/local/bin`, `~/.npm/bin`, `~/.nvm/versions/...`, `/opt/homebrew/bin` + +`spawn('claude', [...])` → **ENOENT** ("command not found") + +### Где Claude CLI устанавливается + +| Метод | Путь бинарника | +|-------|----------------| +| npm global | `/usr/local/bin/claude` (symlink) | +| npm global (user) | `~/.npm-global/bin/claude` | +| nvm + npm | `~/.nvm/versions/node/v20/bin/claude` | +| Homebrew (Intel) | `/usr/local/bin/claude` | +| Homebrew (Apple Silicon) | `/opt/homebrew/bin/claude` | +| Windows | `C:\Users\{user}\AppData\Roaming\npm\claude.cmd` | + +### Решение: ClaudeBinaryResolver + +```typescript +async function resolveClaudeBinary(): Promise { + const candidates = [ + // 1. Try current PATH first (works in terminal) + 'claude', + // 2. Common npm global paths + path.join(os.homedir(), '.npm-global', 'bin', 'claude'), + path.join(os.homedir(), '.npm', 'bin', 'claude'), + // 3. System paths + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', // Apple Silicon + // 4. nvm — find active version + ...await findNvmClaudePaths(), + // 5. Windows + ...(process.platform === 'win32' + ? [`${process.env.APPDATA}\\npm\\claude.cmd`] + : []), + ]; + + for (const candidate of candidates) { + try { + await fs.promises.access(candidate, fs.constants.X_OK); + return candidate; + } catch { continue; } + } + + // Fallback: `which claude` + try { + const { stdout } = await execFileAsync('which', ['claude'], { + timeout: 2000, shell: true + }); + return stdout.trim() || null; + } catch { + return null; + } +} +``` + +### Существующие паттерны в проекте + +- `config.ts:570-604` — пробует несколько editor candidates (cursor, code, subl, zed) +- `SshConnectionManager.ts:325` — использует ПОЛНЫЙ путь: `/bin/launchctl` +- `fix-path` / `shell-path` НЕ используются в проекте + +--- + +## 3. stdio vs File Monitoring + +### Сравнительная матрица + +| Критерий | stdio Pipes | File Monitoring | +|----------|-------------|-----------------| +| **Потеря данных** | ВЫСОКИЙ РИСК (buffer overflow 64KB) | НЕТ РИСКА (файлы на диске) | +| **Latency** | ~0ms (pipe events) | 0-100ms (fs.watch), 3-30s (polling) | +| **Structured data** | Нужен парсинг (NDJSON или raw text) | JSONL уже structured | +| **Crash recovery** | Потеря буфера | Файлы сохранены | +| **Memory** | Накапливает в буферах | Читаем инкрементально | +| **Complexity** | State machine + backpressure + timeouts | Уже реализовано (FileWatcher) | +| **Fragility** | Ломается при смене формата CLI | JSONL = stable contract | +| **Масштаб** | 10 agents × stdio = resource explosion | Один watcher на все файлы | + +### Конкретные риски stdio + +1. **Buffer overflow**: stdout buffer = 64KB. Claude team session легко генерит > 64KB → subprocess deadlock +2. **Partial JSON reads**: chunk boundary может порвать JSON object → нужен stateful line parser +3. **Backpressure**: если не drain'ить pipe → Claude CLI зависает навсегда +4. **Encoding**: multi-byte UTF-8 рвётся на chunk boundaries +5. **ANSI escapes**: Claude может слать цветные коды (даже в pipe mode) + +### Вердикт + +**Hybrid**: stdio ТОЛЬКО для lifecycle (started/stopped/crashed), файлы для ВСЕХ данных. + +--- + +## 4. Сравнение подходов + +| | **SDK query()** | **CLI + stream-json** | **CLI + file monitoring** | +|---|---|---|---| +| Startup | ~12 сек (!) | ~2-3 сек | ~2-3 сек | +| Structured output | SDKMessage | NDJSON events | JSONL files | +| TeamCreate detection | Да | Да (tool_use event) | Да (config.json appears) | +| Multi-turn | Stateless per query | Баг #5034 | Работает | +| stdio risks | Hidden in SDK | Buffer/encoding/deadlock | Нет (файлы) | +| Crash recovery | Потеря | Потеря | Автоматическое | +| PATH resolution | SDK сам резолвит | Нужен resolver | Нужен resolver | +| API stability | Pre-1.0 | Experimental | JSONL stable | +| Hot reuse | Нет (issue #34) | N/A | N/A | + +--- + +## 5. Env Variable для Team Tools + +Team tools **выключены по умолчанию**. Для включения: + +```bash +CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 +``` + +Или в `~/.claude/settings.json`: + +```json +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + } +} +``` + +Доступные team tools: +- `TeamCreate` — создание команды +- `TaskCreate` — создание задачи +- `TaskUpdate` — обновление статуса задачи +- `TaskList` — список задач +- `TaskGet` — получение задачи по ID +- `SendMessage` — отправка сообщения участнику +- `TeamDelete` — удаление команды + +--- + +## 6. Ограничения Agent Teams (из официальной документации) + +1. `/resume` и `/rewind` ломают team state +2. Task status может отставать — teammates иногда не отмечают tasks completed +3. Shutdown медленный — teammates завершают текущий request перед выходом +4. Один team per session — нельзя управлять несколькими командами из одного lead +5. Нет nested teams — teammates не могут создавать свои команды +6. Token cost высокий — каждый teammate = отдельное context window +7. Split-pane mode fragile — требует tmux или iTerm2 + +--- + +## 7. Верификация (раунд 3, 3 агента) + +### 7.1 Вердикт по SDK: НЕТ + +**Финальный вердикт**: SDK query() НЕ использовать. + +Причины: +1. SDK **внутри спавнит CLI как subprocess** — нет выигрыша по сравнению с прямым spawn +2. ~12 сек cold start per query() call — неприемлемо для UX +3. Pre-1.0: API может измениться без предупреждения +4. SDK **не решает** проблему PATH resolution — всё равно нужен resolver +5. Team features experimental — задокументированные баги с session resumption, task lag, slow shutdown +6. Никакой добавленной ценности: FileWatcher уже делает всё, что мог бы дать SDK streaming + +### 7.2 Верификация file monitoring: ОК с оговорками + +**Находка**: CLI flag `-p` (print mode) **выходит после первого ответа**. Для multi-turn team creation: +- Вариант A: `--continue --resume $SESSION_ID` для продолжения сессии +- Вариант B: **Одноходовый промпт** — Claude за один turn вызывает TeamCreate + TaskCreate + спавнит teammates +- **Рекомендация**: Вариант B (одноходовый) — достаточно для bootstrap, проще и надёжнее + +**Session JSONL parsing**: В JSONL session файле видны: +- `tool_use` с `name: "TeamCreate"` → команда создана +- `tool_result` с `teammate_spawned` → участник запущен +- SubagentResolver в проекте **уже умеет** детектить `teammate_spawned` + +**Edge cases**: +- Mid-creation crash → retry safe (TeamCreate идемпотентен — перезаписывает config.json) +- Concurrent creation attempts → лок на teamName (validateTeamName + state machine) +- Dir exists without config.json → graceful skip (уже исправлено в v7, баг #37) +- Tasks dir ENOENT → return [] (уже исправлено в v7, баг #38) + +### 7.3 Финальная архитектура (верифицирована) + +#### Новые файлы (7) + +| Файл | Описание | +|------|----------| +| `src/main/services/teams/ClaudeBinaryResolver.ts` | Поиск бинарника Claude CLI | +| `src/main/services/teams/TeamProvisioningService.ts` | State machine для создания команды | +| `src/main/services/teams/types.ts` | Типы для provisioning | +| `src/main/ipc/teams.ts` | IPC handlers для provisioning | +| `src/preload/constants/teamChannels.ts` | IPC channel constants | +| `src/renderer/hooks/useTeamProvisioning.ts` | React hook | +| `src/renderer/components/teams/CreateTeamDialog.tsx` | UI диалог создания | + +#### Модификации (5) + +| Файл | Изменение | +|------|-----------| +| `src/main/ipc/handlers.ts` | + registerTeamProvisioningHandlers | +| `src/preload/index.ts` | + api.teams.create, api.teams.getProvisioningStatus | +| `src/renderer/store/slices/teamSlice.ts` | + provisioning state | +| `src/main/services/infrastructure/FileWatcher.ts` | Уже добавлен в v7 (teamsWatcher) | +| `src/shared/types/ElectronAPI.ts` | + provisioning types | + +#### State Machine + +``` +idle → validating → spawning → monitoring → ready + ↘ failed → idle (retry) +``` + +- `idle` — начальное состояние +- `validating` — проверка teamName, поиск Claude binary +- `spawning` — spawn CLI process +- `monitoring` — ожидание config.json через FileWatcher +- `ready` — команда создана, participants bootstrapped +- `failed` — ошибка (с retry) + +#### IPC Contract + +```typescript +// Channels +export const TEAM_CREATE = 'team:create'; +export const TEAM_PROVISIONING_STATUS = 'team:provisioning-status'; +export const TEAM_PROVISIONING_PROGRESS = 'team:provisioning-progress'; // event + +// Request +interface TeamCreateRequest { + teamName: string; + description: string; + members: Array<{ name: string; role: string }>; + cwd: string; // project working directory +} + +// Response (via event stream) +interface ProvisioningProgress { + teamName: string; + state: 'validating' | 'spawning' | 'monitoring' | 'ready' | 'failed'; + message: string; + error?: string; + sessionId?: string; +} +``` + +#### Spawn Strategy + +```typescript +const claudePath = await ClaudeBinaryResolver.resolve(); +if (!claudePath) throw new Error('Claude CLI not found'); + +const child = spawn(claudePath, [ + '-p', prompt, // print mode (single turn) + '--output-format', 'text', // НЕ парсим stdout +], { + cwd: projectPath, + env: { + ...process.env, + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + }, + stdio: ['ignore', 'ignore', 'pipe'], // stdout ИГНОРИРУЕМ +}); + +// Только stderr для ошибок +child.stderr.on('data', (data) => { stderrBuffer += data; }); + +// Lifecycle +child.on('exit', (code) => { + if (code === 0) transitionTo('monitoring'); + else transitionTo('failed', stderrBuffer); +}); +child.on('error', (err) => transitionTo('failed', err.message)); +``` + +#### Bootstrap Detection + +```typescript +// FileWatcher УЖЕ следит за ~/.claude/teams/ +// При появлении config.json → проверяем содержимое + +function onTeamConfigCreated(teamName: string) { + const config = await readTeamConfig(teamName); + if (config && config.members?.length > 0) { + transitionTo('ready'); + emitProgress({ state: 'ready', sessionId }); + } +} +``` + +#### Idempotency + +1. `validateTeamName()` проверяет формат (a-z, 0-9, -) +2. Если dir уже существует с config.json → **ошибка** (не перезаписываем чужую команду) +3. Если dir существует БЕЗ config.json → **очищаем и продолжаем** (неудачная предыдущая попытка) +4. PID tracking для active processes → нельзя запустить два provisioning для одного teamName + +--- + +## 8. Итоговая рекомендация + +### Подход: CLI spawn + FileWatcher (hybrid) + +``` +[UI] → [IPC: team:create] → [TeamProvisioningService] + ↓ + [ClaudeBinaryResolver.resolve()] + ↓ + [spawn(claudePath, ['-p', prompt], { + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }, + stdio: ['ignore', 'ignore', 'pipe'] + })] + ↓ + [FileWatcher: ~/.claude/teams/{name}/config.json] + ↓ + [IPC event: team:provisioning-progress → 'ready'] +``` + +### Почему НЕ другие варианты + +| Вариант | Причина отказа | +|---------|----------------| +| SDK query() | Внутри = тот же spawn, +12с overhead, pre-1.0, не решает PATH | +| CLI + stream-json | stdio fragile (64KB buffer), баг #5034, нужен stateful parser | +| CLI + stdout pipe | Buffer overflow, encoding, backpressure — все те же риски | +| tmux/iTerm2 | Не подходит для Electron (нет терминала) | + +--- + +## 9. Sources + +### Official Documentation +- [CLI reference](https://code.claude.com/docs/en/cli-reference) +- [Run Claude Code programmatically](https://code.claude.com/docs/en/headless) +- [Orchestrate teams](https://code.claude.com/docs/en/agent-teams) +- [Agent SDK overview](https://platform.claude.com/docs/en/agent-sdk/overview) +- [Agent SDK TypeScript](https://platform.claude.com/docs/en/agent-sdk/typescript) + +### NPM / GitHub +- [npmjs.com/@anthropic-ai/claude-agent-sdk](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) +- [github.com/anthropics/claude-agent-sdk-typescript](https://github.com/anthropics/claude-agent-sdk-typescript) + +### Known Issues +- [#5034 — Duplicate entries in stream-json](https://github.com/anthropics/claude-code/issues/5034) +- [#1920 — Missing Final Result Event in Streaming JSON](https://github.com/anthropics/claude-code/issues/1920) +- [#34 — Hot process reuse for SDK](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34) +- [#15511 — Stream partial JSON tokens](https://github.com/anthropics/claude-code/issues/15511) + +### Community +- [Building agents with Claude Agent SDK — Anthropic Engineering](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk) +- [Agent Teams with Claude Code — Medium](https://kargarisaac.medium.com/agent-teams-with-claude-code-and-claude-agent-sdk-e7de4e0cb03e) +- [Claude Code Multi-Agent tmux Setup — Dariusz Parys](https://www.dariuszparys.com/claude-code-multi-agent-tmux-setup/) diff --git a/package.json b/package.json index ac50ab09..1844e19c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ }, "main": "dist-electron/main/index.cjs", "scripts": { - "dev": "electron-vite dev", + "dev": "trap 'kill 0' INT; electron-vite dev", + "dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'", "build": "electron-vite build", "dist": "electron-builder --mac --win --linux", "dist:mac": "electron-builder --mac --publish always", @@ -53,7 +54,16 @@ "@dnd-kit/utilities": "^3.2.2", "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-virtual": "^3.10.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.4", "date-fns": "^3.6.0", "electron-updater": "^6.7.3", "fastify": "^5.7.4", @@ -67,6 +77,8 @@ "remark-parse": "^11.0.0", "ssh-config": "^5.0.4", "ssh2": "^1.17.0", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", "zustand": "^4.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756829c8..86b0f669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,36 @@ importers: '@fastify/static': specifier: ^9.0.0 version: 9.0.0 + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@18.3.27)(react@18.3.1) '@tanstack/react-virtual': specifier: ^3.10.8 version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: 1.0.4 + version: 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -65,6 +92,12 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) unified: specifier: ^11.0.5 version: 11.0.5 @@ -746,6 +779,21 @@ packages: '@fastify/static@9.0.0': resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -954,6 +1002,345 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1534,6 +1921,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1807,6 +2198,9 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -1834,6 +2228,16 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.0.4: + resolution: {integrity: sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2011,6 +2415,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -2525,6 +2932,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3769,6 +4180,36 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -4190,6 +4631,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -4400,6 +4849,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -5150,6 +5619,23 @@ snapshots: fastq: 1.20.1 glob: 13.0.2 + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.10': {} + '@gar/promisify@1.1.3': {} '@humanfs/core@0.19.1': {} @@ -5331,6 +5817,320 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@radix-ui/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.55.1': @@ -5972,6 +6772,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6329,6 +7133,10 @@ snapshots: ci-info@3.9.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + clean-stack@2.2.0: {} cli-cursor@3.1.0: @@ -6355,6 +7163,20 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + + cmdk@1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6508,6 +7330,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + detect-node@2.1.0: optional: true @@ -7320,6 +8144,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -8791,6 +9617,33 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + + react-remove-scroll@2.7.2(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -9330,6 +10183,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@3.5.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 @@ -9615,6 +10474,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + + use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src/main/index.ts b/src/main/index.ts index b6c990a8..d911f96c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,6 +49,7 @@ const CONTEXT_CHANGED = 'context:changed'; const HTTP_SERVER_START = 'httpServer:start'; const HTTP_SERVER_STOP = 'httpServer:stop'; const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; +const TEAM_CHANGE = 'team:change'; process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection in main process:', reason); @@ -67,6 +68,7 @@ import { ServiceContextRegistry, SshConnectionManager, TeamDataService, + TeamProvisioningService, UpdaterService, } from './services'; @@ -82,11 +84,13 @@ let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; let teamDataService: TeamDataService; +let teamProvisioningService: TeamProvisioningService; let httpServer: HttpServer; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null; +let teamChangeCleanup: (() => void) | null = null; /** * Resolve production renderer index path. @@ -116,6 +120,10 @@ function wireFileWatcherEvents(context: ServiceContext): void { todoChangeCleanup(); todoChangeCleanup = null; } + if (teamChangeCleanup) { + teamChangeCleanup(); + teamChangeCleanup = null; + } // Wire file-change events to renderer and HTTP SSE const fileChangeHandler = (event: unknown): void => { @@ -137,6 +145,16 @@ function wireFileWatcherEvents(context: ServiceContext): void { context.fileWatcher.on('todo-change', todoChangeHandler); todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler); + // Forward team-change events to renderer and HTTP SSE + const teamChangeHandler = (event: unknown): void => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(TEAM_CHANGE, event); + } + httpServer?.broadcast('team-change', event); + }; + context.fileWatcher.on('team-change', teamChangeHandler); + teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler); + logger.info(`FileWatcher events wired for context: ${context.id}`); } @@ -267,16 +285,24 @@ function initializeServices(): void { // Initialize updater service updaterService = new UpdaterService(); teamDataService = new TeamDataService(); + teamProvisioningService = new TeamProvisioningService(); httpServer = new HttpServer(); // Initialize IPC handlers with registry - initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, teamDataService, { - rewire: rewireContextEvents, - full: onContextSwitched, - onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { - reconfigureLocalContextForClaudeRoot(); - }, - }); + initializeIpcHandlers( + contextRegistry, + updaterService, + sshConnectionManager, + teamDataService, + teamProvisioningService, + { + rewire: rewireContextEvents, + full: onContextSwitched, + onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { + reconfigureLocalContextForClaudeRoot(); + }, + } + ); // HTTP Server control IPC handlers ipcMain.handle(HTTP_SERVER_START, async () => { diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index b544c151..26f4aa74 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -12,6 +12,10 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const TASK_ID_PATTERN = /^\d{1,10}$/; +const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const MAX_QUERY_LENGTH = 512; const MAX_RESULTS = 200; @@ -109,6 +113,58 @@ export function validateTriggerId(triggerId: unknown): ValidationResult return { valid: true, value: basic.value }; } +export function validateTeamName(teamName: unknown): ValidationResult { + const basic = validateString(teamName, 'teamName', 128); + if (!basic.valid) { + return basic; + } + + if (!TEAM_NAME_PATTERN.test(basic.value!)) { + return { valid: false, error: 'teamName contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateTaskId(taskId: unknown): ValidationResult { + const basic = validateString(taskId, 'taskId', 16); + if (!basic.valid) { + return basic; + } + + if (!TASK_ID_PATTERN.test(basic.value!)) { + return { valid: false, error: 'taskId must contain only digits' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateMemberName(memberName: unknown): ValidationResult { + const basic = validateString(memberName, 'member', 128); + if (!basic.valid) { + return basic; + } + + if (!MEMBER_NAME_PATTERN.test(basic.value!)) { + return { valid: false, error: 'member contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateFromField(from: unknown): ValidationResult { + const basic = validateString(from, 'from', 128); + if (!basic.valid) { + return basic; + } + + if (!FROM_FIELD_PATTERN.test(basic.value!)) { + return { valid: false, error: 'from contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + export function validateSearchQuery(query: unknown): ValidationResult { if (typeof query !== 'string') { return { valid: false, error: 'query must be a string' }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b2763336..f987b892 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -57,6 +57,7 @@ import type { ServiceContextRegistry, SshConnectionManager, TeamDataService, + TeamProvisioningService, UpdaterService, } from '../services'; @@ -68,6 +69,7 @@ export function initializeIpcHandlers( updater: UpdaterService, sshManager: SshConnectionManager, teamDataService: TeamDataService, + teamProvisioningService: TeamProvisioningService, contextCallbacks: { rewire: (context: ServiceContext) => void; full: (context: ServiceContext) => void; @@ -82,7 +84,7 @@ export function initializeIpcHandlers( initializeUpdaterHandlers(updater); initializeSshHandlers(sshManager, registry, contextCallbacks.rewire); initializeContextHandlers(registry, contextCallbacks.rewire); - initializeTeamHandlers(teamDataService); + initializeTeamHandlers(teamDataService, teamProvisioningService); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, }); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 78799b26..183f0327 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1,25 +1,94 @@ -import { TEAM_LIST } from '@preload/constants/ipcChannels'; +import { + TEAM_ALIVE_LIST, + TEAM_CANCEL_PROVISIONING, + TEAM_CREATE, + TEAM_CREATE_TASK, + TEAM_DELETE_TEAM, + TEAM_GET_DATA, + TEAM_LIST, + TEAM_PREPARE_PROVISIONING, + TEAM_PROCESS_ALIVE, + TEAM_PROCESS_SEND, + TEAM_PROVISIONING_PROGRESS, + TEAM_PROVISIONING_STATUS, + TEAM_REQUEST_REVIEW, + TEAM_SEND_MESSAGE, + TEAM_UPDATE_KANBAN, + TEAM_UPDATE_TASK_STATUS, + // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design +} from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; -import type { TeamDataService } from '../services'; -import type { IpcResult, TeamSummary } from '@shared/types'; +import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards'; + +import type { TeamDataService, TeamProvisioningService } from '../services'; +import type { + CreateTaskRequest, + IpcResult, + SendMessageRequest, + SendMessageResult, + TeamCreateRequest, + TeamCreateResponse, + TeamData, + TeamProvisioningPrepareResult, + TeamProvisioningProgress, + TeamSummary, + TeamTask, + TeamTaskStatus, + UpdateKanbanPatch, +} from '@shared/types'; const logger = createLogger('IPC:teams'); let teamDataService: TeamDataService | null = null; +let teamProvisioningService: TeamProvisioningService | null = null; -export function initializeTeamHandlers(service: TeamDataService): void { +export function initializeTeamHandlers( + service: TeamDataService, + provisioningService: TeamProvisioningService +): void { teamDataService = service; + teamProvisioningService = provisioningService; } export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); + ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); + ipcMain.handle(TEAM_CREATE, handleCreateTeam); + ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); + ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); + ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); + ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); + ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); + ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); + ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus); + ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam); + ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); + ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); + ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); logger.info('Team handlers registered'); } export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); + ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); + ipcMain.removeHandler(TEAM_CREATE); + ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); + ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); + ipcMain.removeHandler(TEAM_SEND_MESSAGE); + ipcMain.removeHandler(TEAM_CREATE_TASK); + ipcMain.removeHandler(TEAM_REQUEST_REVIEW); + ipcMain.removeHandler(TEAM_UPDATE_KANBAN); + ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS); + ipcMain.removeHandler(TEAM_DELETE_TEAM); + ipcMain.removeHandler(TEAM_PROCESS_SEND); + ipcMain.removeHandler(TEAM_PROCESS_ALIVE); + ipcMain.removeHandler(TEAM_ALIVE_LIST); } function getTeamDataService(): TeamDataService { @@ -29,6 +98,13 @@ function getTeamDataService(): TeamDataService { return teamDataService; } +function getTeamProvisioningService(): TeamProvisioningService { + if (!teamProvisioningService) { + throw new Error('Team provisioning handlers are not initialized'); + } + return teamProvisioningService; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -46,3 +122,401 @@ async function wrapTeamHandler( async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { return wrapTeamHandler('list', () => getTeamDataService().listTeams()); } + +async function handleGetData( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('getData', () => getTeamDataService().getTeamData(validated.value!)); +} + +async function handleDeleteTeam( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!)); +} + +function isProvisioningTeamName(teamName: string): boolean { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64; +} + +async function validateProvisioningRequest( + request: unknown +): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { + if (!request || typeof request !== 'object') { + return { valid: false, error: 'Invalid team create request' }; + } + + const payload = request as Partial; + if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) { + return { valid: false, error: 'teamName is required' }; + } + const teamName = payload.teamName.trim(); + if (!isProvisioningTeamName(teamName)) { + return { valid: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' }; + } + + if (payload.displayName !== undefined && typeof payload.displayName !== 'string') { + return { valid: false, error: 'displayName must be string' }; + } + if (payload.description !== undefined && typeof payload.description !== 'string') { + return { valid: false, error: 'description must be string' }; + } + + if (!Array.isArray(payload.members) || payload.members.length === 0) { + return { valid: false, error: 'members must contain at least one member' }; + } + + const seenNames = new Set(); + const members: TeamCreateRequest['members'] = []; + for (const member of payload.members) { + if (!member || typeof member !== 'object') { + return { valid: false, error: 'member must be object' }; + } + const nameValidation = validateMemberName((member as { name?: unknown }).name); + if (!nameValidation.valid) { + return { valid: false, error: nameValidation.error ?? 'Invalid member name' }; + } + const memberName = nameValidation.value!; + if (seenNames.has(memberName)) { + return { valid: false, error: 'member names must be unique' }; + } + seenNames.add(memberName); + + const role = (member as { role?: unknown }).role; + if (role !== undefined && typeof role !== 'string') { + return { valid: false, error: 'member role must be string' }; + } + members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined }); + } + + if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { + return { valid: false, error: 'cwd is required' }; + } + const cwd = payload.cwd.trim(); + if (!path.isAbsolute(cwd)) { + return { valid: false, error: 'cwd must be an absolute path' }; + } + + try { + await fs.promises.mkdir(cwd, { recursive: true }); + } catch { + return { valid: false, error: 'failed to create cwd directory' }; + } + + let stat: fs.Stats; + try { + stat = await fs.promises.stat(cwd); + } catch { + return { valid: false, error: 'cwd does not exist' }; + } + if (!stat.isDirectory()) { + return { valid: false, error: 'cwd must be a directory' }; + } + + return { + valid: true, + value: { + teamName, + displayName: payload.displayName?.trim() || undefined, + description: payload.description?.trim() || undefined, + members, + cwd, + }, + }; +} + +async function handleCreateTeam( + event: IpcMainInvokeEvent, + request: unknown +): Promise> { + const validation = await validateProvisioningRequest(request); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + return wrapTeamHandler('create', () => + getTeamProvisioningService().createTeam(validation.value, (progress) => { + try { + event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to emit provisioning progress: ${message}`); + } + }) + ); +} + +async function handlePrepareProvisioning( + _event: IpcMainInvokeEvent, + cwd: unknown +): Promise> { + let validatedCwd: string | undefined; + if (cwd !== undefined) { + if (typeof cwd !== 'string' || cwd.trim().length === 0) { + return { success: false, error: 'cwd must be a non-empty string' }; + } + validatedCwd = cwd.trim(); + if (!path.isAbsolute(validatedCwd)) { + return { success: false, error: 'cwd must be an absolute path' }; + } + } + return wrapTeamHandler('prepareProvisioning', () => + getTeamProvisioningService().prepareForProvisioning(validatedCwd) + ); +} + +async function handleProvisioningStatus( + _event: IpcMainInvokeEvent, + runId: unknown +): Promise> { + if (typeof runId !== 'string' || runId.trim().length === 0) { + return { success: false, error: 'runId is required' }; + } + return wrapTeamHandler('provisioningStatus', () => + getTeamProvisioningService().getProvisioningStatus(runId.trim()) + ); +} + +async function handleCancelProvisioning( + _event: IpcMainInvokeEvent, + runId: unknown +): Promise> { + if (typeof runId !== 'string' || runId.trim().length === 0) { + return { success: false, error: 'runId is required' }; + } + return wrapTeamHandler('cancelProvisioning', () => + getTeamProvisioningService().cancelProvisioning(runId.trim()) + ); +} + +function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch { + if (!value || typeof value !== 'object') { + return false; + } + + const patch = value as Partial & { op?: unknown; column?: unknown }; + if (patch.op === 'remove') { + return true; + } + + if (patch.op === 'request_changes') { + return patch.comment === undefined || typeof patch.comment === 'string'; + } + + return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved'); +} + +async function handleSendMessage( + _event: IpcMainInvokeEvent, + teamName: unknown, + request: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid send message request' }; + } + + const payload = request as Partial; + const validatedMember = validateMemberName(payload.member); + if (!validatedMember.valid) { + return { success: false, error: validatedMember.error ?? 'Invalid member' }; + } + if (typeof payload.text !== 'string' || payload.text.trim().length === 0) { + return { success: false, error: 'text must be non-empty string' }; + } + if (payload.summary !== undefined && typeof payload.summary !== 'string') { + return { success: false, error: 'summary must be string' }; + } + if (payload.from !== undefined) { + const validatedFrom = validateFromField(payload.from); + if (!validatedFrom.valid) { + return { success: false, error: validatedFrom.error ?? 'Invalid from' }; + } + } + + return wrapTeamHandler('sendMessage', () => + getTeamDataService().sendMessage(validatedTeamName.value!, { + member: validatedMember.value!, + text: payload.text!, + summary: payload.summary, + from: payload.from, + }) + ); +} + +async function handleCreateTask( + _event: IpcMainInvokeEvent, + teamName: unknown, + request: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid create task request' }; + } + + const payload = request as Partial; + if (typeof payload.subject !== 'string' || payload.subject.trim().length === 0) { + return { success: false, error: 'subject must be a non-empty string' }; + } + if (payload.subject.trim().length > 500) { + return { success: false, error: 'subject exceeds max length (500)' }; + } + if (payload.description !== undefined && typeof payload.description !== 'string') { + return { success: false, error: 'description must be string' }; + } + if (payload.owner !== undefined) { + const validatedOwner = validateMemberName(payload.owner); + if (!validatedOwner.valid) { + return { success: false, error: validatedOwner.error ?? 'Invalid owner' }; + } + } + if (payload.blockedBy !== undefined) { + if ( + !Array.isArray(payload.blockedBy) || + payload.blockedBy.some((id) => typeof id !== 'string') + ) { + return { success: false, error: 'blockedBy must be an array of task ID strings' }; + } + } + + return wrapTeamHandler('createTask', () => + getTeamDataService().createTask(validatedTeamName.value!, { + subject: payload.subject!.trim(), + description: payload.description?.trim(), + owner: payload.owner?.trim() || undefined, + blockedBy: payload.blockedBy, + }) + ); +} + +async function handleRequestReview( + _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('requestReview', () => + getTeamDataService().requestReview(validatedTeamName.value!, validatedTaskId.value!) + ); +} + +async function handleUpdateKanban( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + patch: 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' }; + } + + if (!isUpdateKanbanPatch(patch)) { + return { success: false, error: 'Invalid kanban patch' }; + } + + return wrapTeamHandler('updateKanban', async () => { + await getTeamDataService().updateKanban( + validatedTeamName.value!, + validatedTaskId.value!, + patch + ); + }); +} + +const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed']; + +async function handleUpdateTaskStatus( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + status: 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' }; + } + + if (typeof status !== 'string' || !VALID_TASK_STATUSES.includes(status as TeamTaskStatus)) { + return { success: false, error: `status must be one of: ${VALID_TASK_STATUSES.join(', ')}` }; + } + + return wrapTeamHandler('updateTaskStatus', () => + getTeamDataService().updateTaskStatus( + validatedTeamName.value!, + validatedTaskId.value!, + status as TeamTaskStatus + ) + ); +} + +async function handleProcessSend( + _event: IpcMainInvokeEvent, + teamName: unknown, + message: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + if (typeof message !== 'string' || message.trim().length === 0) { + return { success: false, error: 'message must be a non-empty string' }; + } + return wrapTeamHandler('processSend', () => + getTeamProvisioningService().sendMessageToTeam(validatedTeamName.value!, message) + ); +} + +async function handleProcessAlive( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('processAlive', async () => + getTeamProvisioningService().isTeamAlive(validatedTeamName.value!) + ); +} + +async function handleAliveList(_event: IpcMainInvokeEvent): Promise> { + return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams()); +} diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index b359e226..ba7555b0 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -12,7 +12,12 @@ import { type FileChangeEvent, type ParsedMessage } from '@main/types'; import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; -import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder'; +import { + getProjectsBasePath, + getTasksBasePath, + getTeamsBasePath, + getTodosBasePath, +} from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { EventEmitter } from 'events'; import * as fs from 'fs'; @@ -27,6 +32,7 @@ import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { type NotificationManager } from './NotificationManager'; import type { FileSystemProvider, FsDirent } from './FileSystemProvider'; +import type { TeamChangeEvent } from '@shared/types'; const logger = createLogger('Service:FileWatcher'); @@ -54,9 +60,13 @@ interface ActiveSessionFile { export class FileWatcher extends EventEmitter { private projectsWatcher: fs.FSWatcher | null = null; private todosWatcher: fs.FSWatcher | null = null; + private teamsWatcher: fs.FSWatcher | null = null; + private tasksWatcher: fs.FSWatcher | null = null; private retryTimer: NodeJS.Timeout | null = null; private projectsPath: string; private todosPath: string; + private teamsPath: string; + private tasksPath: string; private dataCache: DataCache; private fsProvider: FileSystemProvider; private notificationManager: NotificationManager | null = null; @@ -96,6 +106,8 @@ export class FileWatcher extends EventEmitter { super(); this.projectsPath = projectsPath ?? getProjectsBasePath(); this.todosPath = todosPath ?? getTodosBasePath(); + this.teamsPath = getTeamsBasePath(); + this.tasksPath = getTasksBasePath(); this.dataCache = dataCache; this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } @@ -163,6 +175,16 @@ export class FileWatcher extends EventEmitter { this.todosWatcher = null; } + if (this.teamsWatcher) { + this.teamsWatcher.close(); + this.teamsWatcher = null; + } + + if (this.tasksWatcher) { + this.tasksWatcher.close(); + this.tasksWatcher = null; + } + // Clear any pending debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); @@ -317,6 +339,62 @@ export class FileWatcher extends EventEmitter { } } + /** + * Starts the teams directory watcher. + */ + private startTeamsWatcher(): void { + if (this.teamsWatcher) { + return; + } + + try { + if (!fs.existsSync(this.teamsPath)) { + this.scheduleWatcherRetry(); + return; + } + + this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => { + if (filename) { + this.handleTeamsChange(eventType, filename); + } + }); + this.attachWatcherRecovery(this.teamsWatcher, 'teams'); + logger.info(`FileWatcher: Started watching teams at ${this.teamsPath}`); + } catch (error) { + logger.error('Error starting teams watcher:', error); + this.teamsWatcher = null; + this.scheduleWatcherRetry(); + } + } + + /** + * Starts the tasks directory watcher. + */ + private startTasksWatcher(): void { + if (this.tasksWatcher) { + return; + } + + try { + if (!fs.existsSync(this.tasksPath)) { + this.scheduleWatcherRetry(); + return; + } + + this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => { + if (filename) { + this.handleTasksChange(eventType, filename); + } + }); + this.attachWatcherRecovery(this.tasksWatcher, 'tasks'); + logger.info(`FileWatcher: Started watching tasks at ${this.tasksPath}`); + } catch (error) { + logger.error('Error starting tasks watcher:', error); + this.tasksWatcher = null; + this.scheduleWatcherRetry(); + } + } + private ensureWatchers(): void { if (!this.isWatching || this.fsProvider.type === 'ssh') { return; @@ -324,8 +402,10 @@ export class FileWatcher extends EventEmitter { this.startProjectsWatcher(); this.startTodosWatcher(); + this.startTeamsWatcher(); + this.startTasksWatcher(); - if (!this.projectsWatcher || !this.todosWatcher) { + if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) { this.scheduleWatcherRetry(); } } @@ -341,13 +421,20 @@ export class FileWatcher extends EventEmitter { }, WATCHER_RETRY_MS); } - private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void { + private attachWatcherRecovery( + watcher: fs.FSWatcher, + watcherType: 'projects' | 'todos' | 'teams' | 'tasks' + ): void { watcher.on('error', (error) => { logger.error(`FileWatcher: ${watcherType} watcher error:`, error); if (watcherType === 'projects') { this.projectsWatcher = null; - } else { + } else if (watcherType === 'todos') { this.todosWatcher = null; + } else if (watcherType === 'teams') { + this.teamsWatcher = null; + } else { + this.tasksWatcher = null; } this.scheduleWatcherRetry(); }); @@ -358,8 +445,12 @@ export class FileWatcher extends EventEmitter { } if (watcherType === 'projects') { this.projectsWatcher = null; - } else { + } else if (watcherType === 'todos') { this.todosWatcher = null; + } else if (watcherType === 'teams') { + this.teamsWatcher = null; + } else { + this.tasksWatcher = null; } this.scheduleWatcherRetry(); }); @@ -808,6 +899,87 @@ export class FileWatcher extends EventEmitter { logger.info(`FileWatcher: ${changeType} todo - ${filename}`); } + private handleTeamsChange(eventType: string, filename: string): void { + try { + this.debounce(`teams/${filename}`, () => this.processTeamsChange(eventType, filename)); + } catch (error) { + logger.error('Error handling teams change:', error); + } + } + + private processTeamsChange(_eventType: string, filename: string): void { + const normalized = filename.split(/[\\/]/).filter(Boolean); + const teamName = normalized[0]; + if (!teamName) { + return; + } + + // `detail` is relative to the team root (plan examples: `inboxes/alice.json`, `config.json`) + const relative = normalized.slice(1).join('/'); + if (!relative) { + return; + } + + // Classify only the paths we care about in iteration 02. + if (normalized.includes('inboxes')) { + const event: TeamChangeEvent = { + type: 'inbox', + teamName, + detail: relative, + }; + this.emit('team-change', event); + return; + } + + if (relative === 'config.json' || relative === 'kanban-state.json') { + const event: TeamChangeEvent = { + type: 'config', + teamName, + detail: relative, + }; + this.emit('team-change', event); + } + } + + private handleTasksChange(eventType: string, filename: string): void { + try { + this.debounce(`tasks/${filename}`, () => this.processTasksChange(eventType, filename)); + } catch (error) { + logger.error('Error handling tasks change:', error); + } + } + + private processTasksChange(_eventType: string, filename: string): void { + const normalized = filename.split(/[\\/]/).filter(Boolean); + const teamName = normalized[0]; + if (!teamName) { + return; + } + + // `detail` is relative to the team tasks dir (plan example: `12.json`) + const relative = normalized.slice(1).join('/'); + if (!relative) { + return; + } + + // Ignore known non-task files in ~/.claude/tasks + if ( + relative === '.lock' || + relative === '.highwatermark' || + relative.startsWith('.') || + !relative.endsWith('.json') + ) { + return; + } + + const event: TeamChangeEvent = { + type: 'task', + teamName, + detail: relative, + }; + this.emit('team-change', event); + } + // =========================================================================== // Catch-Up Scan // =========================================================================== @@ -909,10 +1081,12 @@ export class FileWatcher extends EventEmitter { /** * Returns watched paths. */ - getWatchedPaths(): { projects: string; todos: string } { + getWatchedPaths(): { projects: string; todos: string; teams: string; tasks: string } { return { projects: this.projectsPath, todos: this.todosPath, + teams: this.teamsPath, + tasks: this.tasksPath, }; } } diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts new file mode 100644 index 00000000..3fb087ba --- /dev/null +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +async function isExecutable(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +async function collectNvmCandidates(): Promise { + const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node'); + let versions: string[]; + try { + versions = await fs.promises.readdir(nvmNodeRoot); + } catch { + return []; + } + + return versions + .map((version) => path.join(nvmNodeRoot, version, 'bin', 'claude')) + .sort((a, b) => a.localeCompare(b)) + .reverse(); +} + +async function resolveFromPathEnv(binaryName: string): Promise { + const rawPath = process.env.PATH; + if (!rawPath) { + return null; + } + + const pathParts = rawPath.split(path.delimiter); + for (const part of pathParts) { + if (!part) { + continue; + } + + const candidate = path.join(part, binaryName); + if (await isExecutable(candidate)) { + return candidate; + } + } + return null; +} + +export class ClaudeBinaryResolver { + static async resolve(): Promise { + const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude'; + const fromPath = await resolveFromPathEnv(platformBinaryName); + if (fromPath) { + return fromPath; + } + + const candidates: string[] = [ + path.join(os.homedir(), '.npm-global', 'bin', platformBinaryName), + path.join(os.homedir(), '.npm', 'bin', platformBinaryName), + process.platform === 'win32' + ? path.join(process.env.APPDATA ?? '', 'npm', 'claude.cmd') + : '/usr/local/bin/claude', + process.platform === 'win32' ? '' : '/opt/homebrew/bin/claude', + ].filter((candidate) => candidate.length > 0); + + const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); + for (const candidate of [...candidates, ...nvmCandidates]) { + if (await isExecutable(candidate)) { + return candidate; + } + } + + return null; + } +} diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts new file mode 100644 index 00000000..e47ca601 --- /dev/null +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -0,0 +1,614 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from './atomicWrite'; + +const TOOL_FILE_NAME = 'teamctl.js'; +const TOOL_VERSION = 2; + +function buildTeamCtlScript(): string { + const script = String.raw`#!/usr/bin/env node +'use strict'; + +// Team tools (v${TOOL_VERSION}) + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const TOOL_VERSION = ${TOOL_VERSION}; + +function nowIso() { + return new Date().toISOString(); +} + +function formatError(err) { + if (!err) return 'Unknown error'; + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message || String(err); + try { + return JSON.stringify(err); + } catch { + return String(err); + } +} + +function die(message, code = 1) { + process.stderr.write(String(message).trimEnd() + '\n'); + process.exit(code); +} + +function parseArgs(argv) { + const out = { _: [], flags: {} }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token === '--') { + out._.push(...argv.slice(i + 1)); + break; + } + if (token === '-h' || token === '--help') { + out.flags.help = true; + continue; + } + if (token.startsWith('--')) { + const eq = token.indexOf('='); + if (eq !== -1) { + const key = token.slice(2, eq); + const value = token.slice(eq + 1); + out.flags[key] = value; + continue; + } + const key = token.slice(2); + const next = argv[i + 1]; + if (next != null && !String(next).startsWith('-')) { + out.flags[key] = next; + i++; + } else { + out.flags[key] = true; + } + continue; + } + if (token.startsWith('-') && token.length > 1) { + // minimal short-flag support + for (const ch of token.slice(1)) { + if (ch === 'h') out.flags.help = true; + } + continue; + } + out._.push(token); + } + return out; +} + +function getHomeDir() { + return process.env.HOME || process.env.USERPROFILE || ''; +} + +function getClaudeDir(flags) { + const raw = + (typeof flags['claude-dir'] === 'string' && flags['claude-dir']) || + (typeof flags['claudeDir'] === 'string' && flags['claudeDir']) || + (typeof flags['claude_path'] === 'string' && flags['claude_path']) || + ''; + if (raw) return path.resolve(raw); + const inferred = inferClaudeDirFromScriptPath(); + if (inferred) return inferred; + const home = getHomeDir(); + if (!home) die('HOME is not set'); + return path.join(home, '.claude'); +} + +function inferClaudeDirFromScriptPath() { + // Expected: /teams//tools/teamctl.js + const toolsDir = path.dirname(__filename); + const teamDir = path.dirname(toolsDir); + const teamsDir = path.dirname(teamDir); + if (path.basename(teamsDir) !== 'teams') return null; + const claudeDir = path.dirname(teamsDir); + return claudeDir || null; +} + +function inferTeamNameFromScriptPath() { + // Expected: ~/.claude/teams//tools/teamctl.js + const toolsDir = path.dirname(__filename); + const teamDir = path.dirname(toolsDir); + const parent = path.basename(path.dirname(teamDir)); + if (parent !== 'teams') return null; + const teamName = path.basename(teamDir); + return teamName || null; +} + +function getTeamName(flags) { + const explicit = + (typeof flags.team === 'string' && flags.team.trim()) || + (typeof flags['teamName'] === 'string' && flags['teamName'].trim()) || + ''; + if (explicit) return explicit; + const inferred = inferTeamNameFromScriptPath(); + if (inferred) return inferred; + die('Missing --team (and could not infer team name from script path)'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readJson(filePath, fallback) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err && err.code === 'ENOENT') return fallback; + throw err; + } +} + +function atomicWrite(filePath, data) { + ensureDir(path.dirname(filePath)); + const tmp = + String(filePath) + + '.tmp.' + + String(process.pid) + + '.' + + String(Math.random().toString(16).slice(2)); + fs.writeFileSync(tmp, data, 'utf8'); + fs.renameSync(tmp, filePath); +} + +function normalizeStatus(value) { + const v = String(value || '').trim(); + if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v; + return null; +} + +function normalizeColumn(value) { + const v = String(value || '').trim(); + if (v === 'review' || v === 'approved') return v; + return null; +} + +function getPaths(flags, teamName) { + const claudeDir = getClaudeDir(flags); + const teamDir = path.join(claudeDir, 'teams', teamName); + const tasksDir = path.join(claudeDir, 'tasks', teamName); + const kanbanPath = path.join(teamDir, 'kanban-state.json'); + return { claudeDir, teamDir, tasksDir, kanbanPath }; +} + +function readTask(paths, taskId) { + const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + const task = readJson(taskPath, null); + if (!task) die('Task not found: ' + String(taskId)); + return { taskPath, task }; +} + +function writeTask(taskPath, task) { + atomicWrite(taskPath, JSON.stringify(task, null, 2)); + const verify = readJson(taskPath, null); + if (!verify) die('Task write verification failed'); + return verify; +} + +function setTaskStatus(paths, taskId, status) { + const normalized = normalizeStatus(status); + if (!normalized) die('Invalid status: ' + String(status)); + const { taskPath, task } = readTask(paths, taskId); + task.status = normalized; + writeTask(taskPath, task); +} + +function listTaskIds(tasksDir) { + let entries = []; + try { + entries = fs.readdirSync(tasksDir); + } catch (err) { + if (err && err.code === 'ENOENT') return []; + throw err; + } + const ids = []; + for (const file of entries) { + if (!file.endsWith('.json')) continue; + if (file.startsWith('.')) continue; + const num = Number(file.replace(/\.json$/, '')); + if (!Number.isFinite(num)) continue; + ids.push(String(num)); + } + ids.sort((a, b) => Number(a) - Number(b)); + return ids; +} + +function getNextTaskId(paths) { + const ids = listTaskIds(paths.tasksDir); + const maxFromFiles = ids.length ? Number(ids[ids.length - 1]) : 0; + const hwmPath = path.join(paths.tasksDir, '.highwatermark'); + const hwmRaw = readJson(hwmPath, null); + const maxFromHwm = typeof hwmRaw === 'number' ? hwmRaw : Number(String(hwmRaw || '0').trim()); + const max = Math.max(maxFromFiles, Number.isFinite(maxFromHwm) ? maxFromHwm : 0); + return String(max + 1); +} + +function updateHighwatermark(paths, taskId) { + const hwmPath = path.join(paths.tasksDir, '.highwatermark'); + atomicWrite(hwmPath, String(taskId)); +} + +function createTask(paths, flags) { + const subject = typeof flags.subject === 'string' ? flags.subject.trim() : ''; + if (!subject) die('Missing --subject'); + const description = + typeof flags.description === 'string' + ? flags.description + : typeof flags.desc === 'string' + ? flags.desc + : ''; + const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined; + const explicitStatus = typeof flags.status === 'string' ? flags.status : ''; + const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending'); + const activeForm = + typeof flags.activeForm === 'string' + ? flags.activeForm + : typeof flags['active-form'] === 'string' + ? flags['active-form'] + : undefined; + + ensureDir(paths.tasksDir); + const nextId = getNextTaskId(paths); + const taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); + if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId)); + + const task = { + id: nextId, + subject, + description: String(description || subject), + activeForm: activeForm ? String(activeForm) : undefined, + owner, + status, + blocks: [], + blockedBy: [], + }; + + writeTask(taskPath, task); + updateHighwatermark(paths, nextId); + return task; +} + +function readKanbanState(paths, teamName) { + const fallback = { teamName, reviewers: [], tasks: {} }; + const parsed = readJson(paths.kanbanPath, fallback); + if (!parsed || typeof parsed !== 'object') return fallback; + const reviewers = Array.isArray(parsed.reviewers) + ? parsed.reviewers.filter((r) => typeof r === 'string' && r.trim()) + : []; + const tasks = parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {}; + return { teamName, reviewers, tasks }; +} + +function writeKanbanState(paths, state) { + atomicWrite(paths.kanbanPath, JSON.stringify(state, null, 2)); +} + +function setKanbanColumn(paths, teamName, taskId, column) { + const normalized = normalizeColumn(column); + if (!normalized) die('Invalid column: ' + String(column)); + const state = readKanbanState(paths, teamName); + if (normalized === 'review') { + state.tasks[String(taskId)] = { + column: 'review', + reviewStatus: 'pending', + reviewer: null, + movedAt: nowIso(), + }; + } else { + state.tasks[String(taskId)] = { + column: 'approved', + movedAt: nowIso(), + }; + } + writeKanbanState(paths, state); +} + +function clearKanban(paths, teamName, taskId) { + const state = readKanbanState(paths, teamName); + delete state.tasks[String(taskId)]; + writeKanbanState(paths, state); +} + +function sendInboxMessage(paths, teamName, flags) { + const to = typeof flags.to === 'string' ? flags.to.trim() : ''; + if (!to) die('Missing --to'); + const text = typeof flags.text === 'string' ? flags.text : ''; + if (!text) die('Missing --text'); + const summary = typeof flags.summary === 'string' ? flags.summary : undefined; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + + const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); + ensureDir(path.dirname(inboxPath)); + + const messageId = crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()) + '-' + String(Math.random()); + const payload = { + from, + text, + timestamp: nowIso(), + read: false, + summary, + messageId, + }; + + const existing = readJson(inboxPath, []); + const list = Array.isArray(existing) ? existing : []; + list.push(payload); + atomicWrite(inboxPath, JSON.stringify(list, null, 2)); + const verify = readJson(inboxPath, []); + if (!Array.isArray(verify) || !verify.some((m) => m && m.messageId === messageId)) { + die('Inbox write verification failed'); + } + return { deliveredToInbox: true, messageId }; +} + +function reviewApprove(paths, teamName, taskId, flags) { + setKanbanColumn(paths, teamName, taskId, 'approved'); + const notify = flags.notify === true || flags['notify-owner'] === true; + if (!notify) return; + const { task } = readTask(paths, taskId); + if (!task.owner) return; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + const note = typeof flags.note === 'string' ? flags.note.trim() : ''; + const text = note + ? 'Task #' + String(taskId) + ' approved.\n\n' + note + : 'Task #' + String(taskId) + ' approved.'; + sendInboxMessage(paths, teamName, { + to: task.owner, + text, + summary: 'Approved #' + String(taskId), + from, + }); +} + +function reviewRequestChanges(paths, teamName, taskId, flags) { + const comment = typeof flags.comment === 'string' ? flags.comment.trim() : ''; + const { taskPath, task } = readTask(paths, taskId); + if (!task.owner) die('No owner found for task ' + String(taskId)); + + clearKanban(paths, teamName, taskId); + task.status = 'in_progress'; + writeTask(taskPath, task); + + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + const text = + 'Task #' + + String(taskId) + + ' needs fixes.\n\n' + + (comment || 'Reviewer requested changes.') + + '\n\n' + + 'Please fix and mark it as completed when ready.'; + sendInboxMessage(paths, teamName, { + to: task.owner, + text, + summary: 'Fix request for #' + String(taskId), + from, + }); +} + +function printHelp() { + const inferred = inferTeamNameFromScriptPath(); + const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : ''; + process.stdout.write( + [ + 'teamctl.js v' + String(TOOL_VERSION) + teamHint, + '', + 'Usage:', + ' node teamctl.js task set-status [--team ]', + ' node teamctl.js task complete [--team ]', + ' node teamctl.js task start [--team ]', + ' node teamctl.js task create --subject "..." [--description "..."] [--owner "member"] [--notify --from "member"] [--team ]', + ' node teamctl.js kanban set-column [--team ]', + ' node teamctl.js kanban clear [--team ]', + ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', + ' node teamctl.js review request-changes --comment "..." [--from "member"] [--team ]', + ' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team ]', + '', + 'Options:', + ' --team Team name (if not under ~/.claude/teams//tools)', + ' --claude-dir Override ~/.claude location', + '', + ].join('\n') + ); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.flags.help || args._.length === 0) { + printHelp(); + return; + } + + const domain = args._[0]; + const action = args._[1]; + const rest = args._.slice(2); + + const teamName = getTeamName(args.flags); + const paths = getPaths(args.flags, teamName); + + if (domain === 'task') { + if (action === 'set-status') { + const id = rest[0] || args.flags.id; + const status = rest[1] || args.flags.status; + if (!id || !status) die('Usage: task set-status '); + setTaskStatus(paths, String(id), status); + process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n'); + return; + } + if (action === 'complete' || action === 'done') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: task complete '); + setTaskStatus(paths, String(id), 'completed'); + process.stdout.write('OK task #' + String(id) + ' status=completed\n'); + return; + } + if (action === 'start') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: task start '); + setTaskStatus(paths, String(id), 'in_progress'); + process.stdout.write('OK task #' + String(id) + ' status=in_progress\n'); + return; + } + if (action === 'create') { + const task = createTask(paths, args.flags); + const notify = args.flags.notify === true || args.flags['notify-owner'] === true; + if (notify && task.owner) { + const from = + typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user'; + const text = + 'New task assigned to you: #' + + String(task.id) + + ' "' + + String(task.subject) + + '".\n\n' + + 'When you start: node "$HOME/.claude/teams/' + + String(teamName) + + '/tools/${TOOL_FILE_NAME}" task start ' + + String(task.id) + + '\n' + + 'When done: node "$HOME/.claude/teams/' + + String(teamName) + + '/tools/${TOOL_FILE_NAME}" task complete ' + + String(task.id) + + '\n'; + sendInboxMessage(paths, teamName, { + to: task.owner, + text, + summary: 'New task #' + String(task.id) + ' assigned', + from, + }); + } + process.stdout.write(JSON.stringify(task, null, 2) + '\n'); + return; + } + if (action === 'get') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: task get '); + const { task } = readTask(paths, String(id)); + process.stdout.write(JSON.stringify(task, null, 2) + '\n'); + return; + } + if (action === 'list') { + const ids = listTaskIds(paths.tasksDir); + const tasks = []; + for (const id of ids) { + try { + tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null)); + } catch {} + } + process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); + return; + } + die('Unknown task action: ' + String(action)); + } + + if (domain === 'kanban') { + if (action === 'set-column') { + const id = rest[0] || args.flags.id; + const column = rest[1] || args.flags.column; + if (!id || !column) die('Usage: kanban set-column '); + setKanbanColumn(paths, teamName, String(id), column); + process.stdout.write( + 'OK kanban #' + String(id) + ' column=' + String(column) + '\n' + ); + return; + } + if (action === 'clear' || action === 'remove') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: kanban clear '); + clearKanban(paths, teamName, String(id)); + process.stdout.write('OK kanban #' + String(id) + ' cleared\n'); + return; + } + if (action === 'reviewers') { + const sub = rest[0]; + const name = rest[1]; + const state = readKanbanState(paths, teamName); + if (sub === 'list') { + process.stdout.write(JSON.stringify(state.reviewers, null, 2) + '\n'); + return; + } + if ((sub === 'add' || sub === 'remove') && (!name || !String(name).trim())) { + die('Usage: kanban reviewers add|remove '); + } + const trimmed = String(name || '').trim(); + const before = new Set(state.reviewers); + if (sub === 'add') before.add(trimmed); + else if (sub === 'remove') before.delete(trimmed); + else die('Usage: kanban reviewers list|add|remove ...'); + state.reviewers = [...before]; + writeKanbanState(paths, state); + process.stdout.write('OK reviewers ' + String(sub) + '\n'); + return; + } + die('Unknown kanban action: ' + String(action)); + } + + if (domain === 'review') { + if (action === 'approve') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: review approve '); + reviewApprove(paths, teamName, String(id), args.flags); + process.stdout.write('OK review #' + String(id) + ' approved\n'); + return; + } + if (action === 'request-changes') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: review request-changes --comment "..."'); + reviewRequestChanges(paths, teamName, String(id), args.flags); + process.stdout.write('OK review #' + String(id) + ' requested changes\n'); + return; + } + die('Unknown review action: ' + String(action)); + } + + if (domain === 'message') { + if (action === 'send') { + const result = sendInboxMessage(paths, teamName, args.flags); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + return; + } + die('Unknown message action: ' + String(action)); + } + + die('Unknown domain: ' + String(domain)); +} + +main().catch((err) => { + die(formatError(err)); +}); +`; + + return script.trimStart(); +} + +export class TeamAgentToolsInstaller { + async ensureInstalled(teamName: string): Promise { + const toolsDir = path.join(getTeamsBasePath(), teamName, 'tools'); + const toolPath = path.join(toolsDir, TOOL_FILE_NAME); + await fs.promises.mkdir(toolsDir, { recursive: true }); + + const desired = buildTeamCtlScript(); + let current: string | null = null; + try { + current = await fs.promises.readFile(toolPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + if (current && current.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) { + return toolPath; + } + + await atomicWriteAsync(toolPath, desired); + return toolPath; + } +} diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 2110eaa1..24f4c17b 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -36,7 +36,8 @@ export class TeamConfigReader { const memberCount = Array.isArray(config.members) ? config.members.length : 0; summaries.push({ - name: config.name, + teamName: entry.name, + displayName: config.name, description: typeof config.description === 'string' ? config.description : '', memberCount, taskCount: 0, @@ -49,4 +50,18 @@ export class TeamConfigReader { return summaries; } + + async getConfig(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config = JSON.parse(raw) as TeamConfig; + if (typeof config.name !== 'string' || config.name.trim() === '') { + return null; + } + return config; + } catch { + return null; + } + } } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 87e7833d..0c3f1e8b 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,11 +1,238 @@ -import { TeamConfigReader } from './TeamConfigReader'; +import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; -import type { TeamSummary } from '@shared/types'; +import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; +import { TeamConfigReader } from './TeamConfigReader'; +import { TeamInboxReader } from './TeamInboxReader'; +import { TeamInboxWriter } from './TeamInboxWriter'; +import { TeamKanbanManager } from './TeamKanbanManager'; +import { TeamMemberResolver } from './TeamMemberResolver'; +import { TeamTaskReader } from './TeamTaskReader'; +import { TeamTaskWriter } from './TeamTaskWriter'; + +import type { + CreateTaskRequest, + InboxMessage, + KanbanState, + KanbanTaskState, + SendMessageRequest, + SendMessageResult, + TeamData, + TeamSummary, + TeamTask, + TeamTaskStatus, + UpdateKanbanPatch, +} from '@shared/types'; export class TeamDataService { - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly configReader: TeamConfigReader = new TeamConfigReader(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), + private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(), + private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(), + private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(), + private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), + private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller() + ) {} async listTeams(): Promise { return this.configReader.listTeams(); } + + async deleteTeam(teamName: string): Promise { + const teamsDir = path.join(getTeamsBasePath(), teamName); + await fs.promises.rm(teamsDir, { recursive: true, force: true }); + + const tasksDir = path.join(getTasksBasePath(), teamName); + await fs.promises.rm(tasksDir, { recursive: true, force: true }); + } + + async getTeamData(teamName: string): Promise { + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team not found: ${teamName}`); + } + + const warnings: string[] = []; + + let tasks: TeamTask[] = []; + let tasksLoaded = true; + try { + tasks = await this.taskReader.getTasks(teamName); + } catch { + warnings.push('Tasks failed to load'); + tasksLoaded = false; + } + + let inboxNames: string[] = []; + try { + inboxNames = await this.inboxReader.listInboxNames(teamName); + } catch { + warnings.push('Inboxes failed to load'); + } + + let messages: InboxMessage[] = []; + try { + messages = await this.inboxReader.getMessages(teamName); + } catch { + warnings.push('Messages failed to load'); + } + + let kanbanState: KanbanState = { + teamName, + reviewers: [], + tasks: {}, + }; + let canRunKanbanGc = true; + try { + kanbanState = await this.kanbanManager.getState(teamName); + } catch { + warnings.push('Kanban state failed to load'); + canRunKanbanGc = false; + } + + if (canRunKanbanGc && tasksLoaded) { + try { + await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id))); + kanbanState = await this.kanbanManager.getState(teamName); + } catch { + warnings.push('Kanban state cleanup failed'); + } + } + + const members = this.memberResolver.resolveMembers(config, inboxNames, tasks, messages); + return { + teamName, + config, + tasks, + members, + messages, + kanbanState, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + + async createTask(teamName: string, request: CreateTaskRequest): Promise { + const nextId = await this.taskReader.getNextTaskId(teamName); + + const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? []; + + const task: TeamTask = { + id: nextId, + subject: request.subject, + description: request.description + ? `${request.subject}\n\n${request.description}` + : request.subject, + owner: request.owner, + status: request.owner ? 'in_progress' : 'pending', + blocks: [], + blockedBy, + }; + + await this.taskWriter.createTask(teamName, task); + + // Update blocks[] on each referenced task so the reverse link exists + for (const depId of blockedBy) { + await this.taskWriter.addBlocksEntry(teamName, depId, nextId); + } + + if (request.owner) { + try { + const toolPath = await this.toolsInstaller.ensureInstalled(teamName); + await this.sendMessage(teamName, { + member: request.owner, + text: + `New task assigned to you: #${task.id} "${task.subject}".\n\n` + + `Update task status using:\n` + + `node "${toolPath}" task start ${task.id}\n` + + `node "${toolPath}" task complete ${task.id}\n\n` + + `Help:\n` + + `node "${toolPath}" --help`, + summary: `New task #${task.id} assigned`, + }); + } catch { + // Best-effort notification — don't fail task creation if message fails + } + } + + return task; + } + + async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise { + await this.taskWriter.updateStatus(teamName, taskId, status); + } + + async sendMessage(teamName: string, request: SendMessageRequest): Promise { + return this.inboxWriter.sendMessage(teamName, request); + } + + async requestReview(teamName: string, taskId: string): Promise { + await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' }); + + const state = await this.kanbanManager.getState(teamName); + const reviewer = state.reviewers[0]; + if (!reviewer) { + return; + } + + try { + const toolPath = await this.toolsInstaller.ensureInstalled(teamName); + await this.sendMessage(teamName, { + member: reviewer, + text: + `Please review task #${taskId}.\n\n` + + `When approved, move it to APPROVED:\n` + + `node "${toolPath}" review approve ${taskId}\n\n` + + `If changes are needed:\n` + + `node "${toolPath}" review request-changes ${taskId} --comment "..."`, + summary: `Review request for #${taskId}`, + }); + } catch (error) { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'remove' }) + .catch(() => undefined); + throw error; + } + } + + async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { + if (patch.op !== 'request_changes') { + await this.kanbanManager.updateTask(teamName, taskId, patch); + return; + } + + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((candidate) => candidate.id === taskId); + if (!task?.owner) { + throw new Error(`No owner found for task ${taskId}`); + } + + const previousStatus: TeamTaskStatus = task.status; + const previousState = await this.kanbanManager.getState(teamName); + const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; + + await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' }); + + try { + await this.taskWriter.updateStatus(teamName, taskId, 'in_progress'); + await this.sendMessage(teamName, { + member: task.owner, + text: + `Task #${taskId} needs fixes.\n\n` + + `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + + `Please fix and mark it as completed when ready.`, + summary: `Fix request for #${taskId}`, + }); + } catch (error) { + await this.taskWriter.updateStatus(teamName, taskId, previousStatus).catch(() => undefined); + if (previousKanbanEntry) { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) + .catch(() => undefined); + } + throw error; + } + } } diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts new file mode 100644 index 00000000..aec1e9e1 --- /dev/null +++ b/src/main/services/team/TeamInboxReader.ts @@ -0,0 +1,115 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { InboxMessage } from '@shared/types'; + +export class TeamInboxReader { + async listInboxNames(teamName: string): Promise { + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + + let entries: string[]; + try { + entries = await fs.promises.readdir(inboxDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + return entries + .filter((name) => name.endsWith('.json') && !name.startsWith('.')) + .map((name) => name.replace(/\.json$/, '')); + } + + async getMessagesFor(teamName: string, member: string): Promise { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); + + let raw: string; + try { + raw = await fs.promises.readFile(inboxPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + + const messages: InboxMessage[] = []; + for (const item of parsed) { + if (!item || typeof item !== 'object') { + continue; + } + const row = item as Partial; + if ( + typeof row.from !== 'string' || + typeof row.text !== 'string' || + typeof row.timestamp !== 'string' + ) { + continue; + } + messages.push({ + from: row.from, + to: typeof row.to === 'string' ? row.to : undefined, + text: row.text, + timestamp: row.timestamp, + read: typeof row.read === 'boolean' ? row.read : false, + summary: typeof row.summary === 'string' ? row.summary : undefined, + color: typeof row.color === 'string' ? row.color : undefined, + messageId: typeof row.messageId === 'string' ? row.messageId : undefined, + }); + } + + messages.sort((a, b) => { + const bt = Date.parse(b.timestamp); + const at = Date.parse(a.timestamp); + if (Number.isNaN(bt) || Number.isNaN(at)) { + return 0; + } + return bt - at; + }); + + return messages; + } + + async getMessages(teamName: string): Promise { + const members = await this.listInboxNames(teamName); + const chunks = await Promise.all( + members.map(async (member) => { + try { + const msgs = await this.getMessagesFor(teamName, member); + for (const msg of msgs) { + if (!msg.to) { + msg.to = member; + } + } + return msgs; + } catch { + return [] as InboxMessage[]; + } + }) + ); + + const merged = chunks.flat(); + merged.sort((a, b) => { + const bt = Date.parse(b.timestamp); + const at = Date.parse(a.timestamp); + if (Number.isNaN(bt) || Number.isNaN(at)) { + return 0; + } + return bt - at; + }); + return merged; + } +} diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts new file mode 100644 index 00000000..7dce744a --- /dev/null +++ b/src/main/services/team/TeamInboxWriter.ts @@ -0,0 +1,93 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from './atomicWrite'; + +import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types'; + +const writeLocks = new Map>(); + +async function withInboxLock(inboxPath: string, fn: () => Promise): Promise { + const prev = writeLocks.get(inboxPath) ?? Promise.resolve(); + let release!: () => void; + const mine = new Promise((resolve) => { + release = resolve; + }); + writeLocks.set(inboxPath, mine); + await prev; + try { + return await fn(); + } finally { + release(); + if (writeLocks.get(inboxPath) === mine) { + writeLocks.delete(inboxPath); + } + } +} + +export class TeamInboxWriter { + async sendMessage(teamName: string, request: SendMessageRequest): Promise { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); + const messageId = randomUUID(); + + const payload: InboxMessage = { + from: request.from ?? 'user', + text: request.text, + timestamp: new Date().toISOString(), + read: false, + summary: request.summary, + messageId, + }; + + await withInboxLock(inboxPath, async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const list = await this.readInbox(inboxPath); + list.push(payload); + await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2)); + const written = await this.readInbox(inboxPath); + if (written.some((msg) => msg.messageId === messageId)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + } + throw new Error('Failed to verify inbox write'); + }); + + return { + deliveredToInbox: true, + messageId, + }; + } + + private async readInbox(inboxPath: string): Promise { + let raw: string; + try { + raw = await fs.promises.readFile(inboxPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((item): item is InboxMessage => { + if (!item || typeof item !== 'object') { + return false; + } + const row = item as Partial; + return ( + typeof row.from === 'string' && + typeof row.text === 'string' && + typeof row.timestamp === 'string' && + typeof row.read === 'boolean' + ); + }); + } +} diff --git a/src/main/services/team/TeamKanbanManager.ts b/src/main/services/team/TeamKanbanManager.ts new file mode 100644 index 00000000..39b1816b --- /dev/null +++ b/src/main/services/team/TeamKanbanManager.ts @@ -0,0 +1,136 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from './atomicWrite'; + +import type { KanbanState, UpdateKanbanPatch } from '@shared/types'; + +const logger = createLogger('Service:TeamKanbanManager'); + +function createDefaultState(teamName: string): KanbanState { + return { + teamName, + reviewers: [], + tasks: {}, + }; +} + +function isValidColumn(value: unknown): value is 'review' | 'approved' { + return value === 'review' || value === 'approved'; +} + +export class TeamKanbanManager { + async getState(teamName: string): Promise { + const statePath = this.getStatePath(teamName); + + let raw: string; + try { + raw = await fs.promises.readFile(statePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return createDefaultState(teamName); + } + throw error; + } + + let parsed: Partial; + try { + parsed = JSON.parse(raw) as Partial; + } catch { + return createDefaultState(teamName); + } + const sanitizedTasks: KanbanState['tasks'] = {}; + if (parsed.tasks && typeof parsed.tasks === 'object') { + for (const [taskId, value] of Object.entries(parsed.tasks)) { + if (!value || typeof value !== 'object') { + continue; + } + + const candidate = value as Partial; + if (!isValidColumn(candidate.column) || typeof candidate.movedAt !== 'string') { + continue; + } + + sanitizedTasks[taskId] = { + column: candidate.column, + movedAt: candidate.movedAt, + reviewStatus: + candidate.reviewStatus === 'pending' || candidate.reviewStatus === 'error' + ? candidate.reviewStatus + : undefined, + reviewer: + typeof candidate.reviewer === 'string' || candidate.reviewer === null + ? candidate.reviewer + : undefined, + errorDescription: + typeof candidate.errorDescription === 'string' ? candidate.errorDescription : undefined, + }; + } + } + + return { + teamName, + reviewers: Array.isArray(parsed.reviewers) + ? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0) + : [], + tasks: sanitizedTasks, + }; + } + + async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { + const state = await this.getState(teamName); + + if (patch.op === 'remove' || patch.op === 'request_changes') { + delete state.tasks[taskId]; + } else if (patch.column === 'review') { + state.tasks[taskId] = { + column: 'review', + reviewStatus: 'pending', + reviewer: null, + movedAt: new Date().toISOString(), + }; + } else { + state.tasks[taskId] = { + column: 'approved', + movedAt: new Date().toISOString(), + }; + } + + await this.writeState(teamName, state); + } + + async garbageCollect(teamName: string, validTaskIds: Set): Promise { + const state = await this.getState(teamName); + const before = Object.keys(state.tasks).length; + + for (const taskId of Object.keys(state.tasks)) { + if (!validTaskIds.has(taskId)) { + delete state.tasks[taskId]; + } + } + + const after = Object.keys(state.tasks).length; + if (before === after) { + return; + } + + logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`); + await this.writeState(teamName, state); + } + + private getStatePath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, 'kanban-state.json'); + } + + private async writeState(teamName: string, state: KanbanState): Promise { + const statePath = this.getStatePath(teamName); + const payload: KanbanState = { + teamName, + reviewers: state.reviewers, + tasks: state.tasks, + }; + await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2)); + } +} diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts new file mode 100644 index 00000000..15e422dd --- /dev/null +++ b/src/main/services/team/TeamMemberResolver.ts @@ -0,0 +1,115 @@ +import type { + InboxMessage, + MemberStatus, + ResolvedTeamMember, + TeamConfig, + TeamTask, +} from '@shared/types'; + +export class TeamMemberResolver { + resolveMembers( + config: TeamConfig, + inboxNames: string[], + tasks: TeamTask[], + messages: InboxMessage[] + ): ResolvedTeamMember[] { + const names = new Set(); + + if (Array.isArray(config.members)) { + for (const member of config.members) { + if (typeof member?.name === 'string' && member.name.trim() !== '') { + names.add(member.name.trim()); + } + } + } + + for (const inboxName of inboxNames) { + if (typeof inboxName === 'string' && inboxName.trim() !== '') { + names.add(inboxName.trim()); + } + } + + for (const task of tasks) { + if (typeof task.owner === 'string' && task.owner.trim() !== '') { + names.add(task.owner.trim()); + } + } + + for (const message of messages) { + if (typeof message.from === 'string' && message.from.trim() !== '') { + names.add(message.from.trim()); + } + } + + const configMemberMap = new Map(); + if (Array.isArray(config.members)) { + for (const m of config.members) { + if (typeof m?.name === 'string' && m.name.trim() !== '') { + configMemberMap.set(m.name.trim(), { agentType: m.agentType }); + } + } + } + + const members: ResolvedTeamMember[] = []; + for (const name of names) { + const ownedTasks = tasks.filter((task) => task.owner === name); + const currentTask = ownedTasks.find((task) => task.status === 'in_progress') ?? null; + const memberMessages = messages.filter((message) => message.from === name); + const latestMessage = memberMessages[0] ?? null; + const status = this.resolveStatus(latestMessage); + const configMember = configMemberMap.get(name); + members.push({ + name, + status, + currentTaskId: currentTask?.id ?? null, + taskCount: ownedTasks.length, + messageCount: memberMessages.length, + lastActiveAt: latestMessage?.timestamp ?? null, + color: latestMessage?.color, + agentType: configMember?.agentType, + }); + } + + members.sort((a, b) => a.name.localeCompare(b.name)); + return members; + } + + private resolveStatus(message: InboxMessage | null): MemberStatus { + if (!message) { + return 'unknown'; + } + + const structured = this.parseStructuredMessage(message.text); + if (structured) { + const typed = structured as { type?: string; approve?: boolean; approved?: boolean }; + if ( + (typed.type === 'shutdown_response' && + (typed.approve === true || typed.approved === true)) || + typed.type === 'shutdown_approved' + ) { + return 'terminated'; + } + } + + const ageMs = Date.now() - Date.parse(message.timestamp); + if (Number.isNaN(ageMs)) { + return 'unknown'; + } + if (ageMs < 5 * 60 * 1000) { + return 'active'; + } + return 'idle'; + } + + private parseStructuredMessage(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + } catch { + // Ignore plain text. + } + return null; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts new file mode 100644 index 00000000..1797d82b --- /dev/null +++ b/src/main/services/team/TeamProvisioningService.ts @@ -0,0 +1,1479 @@ +/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ +import { + getAutoDetectedClaudeBasePath, + getClaudeBasePath, + getTasksBasePath, + getTeamsBasePath, +} from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { execFile, spawn } from 'child_process'; +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; + +import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +import { TeamConfigReader } from './TeamConfigReader'; + +import type { + TeamCreateRequest, + TeamCreateResponse, + TeamProvisioningPrepareResult, + TeamProvisioningProgress, + TeamProvisioningState, +} from '@shared/types'; + +const logger = createLogger('Service:TeamProvisioning'); +const RUN_TIMEOUT_MS = 300_000; +const VERIFY_TIMEOUT_MS = 15_000; +const VERIFY_POLL_MS = 500; +const STDERR_RING_LIMIT = 64 * 1024; +const STDOUT_RING_LIMIT = 64 * 1024; +const LOG_PROGRESS_THROTTLE_MS = 300; +const UI_LOGS_TAIL_LIMIT = 8000; +const SHELL_ENV_TIMEOUT_MS = 12000; +const CLI_PREPARE_TIMEOUT_MS = 10000; +const PREFLIGHT_TIMEOUT_MS = 30000; +const KEYCHAIN_TIMEOUT_MS = 5000; +const FS_MONITOR_POLL_MS = 2000; +const TASK_WAIT_FALLBACK_MS = 15_000; + +const execFileAsync = promisify(execFile); + +type TeamsBaseLocation = 'configured' | 'default'; + +type ValidConfigProbeResult = + | { ok: true; location: TeamsBaseLocation; configPath: string } + | { ok: false }; + +function getTeamsBasePathsToProbe(): { location: TeamsBaseLocation; basePath: string }[] { + const configured = getTeamsBasePath(); + const defaultBase = path.join(getAutoDetectedClaudeBasePath(), 'teams'); + if (path.resolve(configured) === path.resolve(defaultBase)) { + return [{ location: 'configured', basePath: configured }]; + } + return [ + { location: 'configured', basePath: configured }, + { location: 'default', basePath: defaultBase }, + ]; +} + +function logsSuggestShutdownOrCleanup(logs: string): boolean { + const text = logs.toLowerCase(); + return ( + text.includes('shutdown') || + text.includes('clean up') || + text.includes('cleanup') || + text.includes('deactivate') || + text.includes('deactivated') || + text.includes('resources') || + // Russian keywords observed in some CLI outputs / user environments + text.includes('очист') || + text.includes('очищ') || + text.includes('заверш') || + text.includes('деактив') + ); +} + +interface ProvisioningRun { + runId: string; + teamName: string; + startedAt: string; + progress: TeamProvisioningProgress; + stdoutBuffer: string; + stderrBuffer: string; + processKilled: boolean; + finalizingByTimeout: boolean; + cancelRequested: boolean; + teamsBasePathsToProbe: { location: TeamsBaseLocation; basePath: string }[]; + child: ReturnType | null; + timeoutHandle: NodeJS.Timeout | null; + fsMonitorHandle: NodeJS.Timeout | null; + onProgress: (progress: TeamProvisioningProgress) => void; + expectedMembers: string[]; + request: TeamCreateRequest; + lastLogProgressAt: number; + fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; + waitingTasksSince: number | null; + provisioningComplete: boolean; +} + +type ProvisioningAuthSource = + | 'anthropic_api_key' + | 'anthropic_auth_token' + | 'claude_code_oauth_token_env' + | 'claude_code_oauth_token_credentials' + | 'none'; + +interface ProvisioningEnvResolution { + env: NodeJS.ProcessEnv; + authSource: ProvisioningAuthSource; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; +let shellEnvResolvePromise: Promise | null = null; + +function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { + const parsed: NodeJS.ProcessEnv = {}; + const lines = content.split('\0'); + for (const line of lines) { + if (!line) { + continue; + } + const separatorIndex = line.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + const key = line.slice(0, separatorIndex); + const value = line.slice(separatorIndex + 1); + parsed[key] = value; + } + return parsed; +} + +async function readShellEnv(shellPath: string, args: string[]): Promise { + const envDump = await new Promise((resolve, reject) => { + const child = spawn(shellPath, args, { + env: process.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const chunks: Buffer[] = []; + let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => { + timeoutHandle = null; + child.kill(); + reject(new Error('shell env resolve timeout')); + }, SHELL_ENV_TIMEOUT_MS); + + child.stdout?.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + child.once('error', (error) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + reject(error); + }); + child.once('close', () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); + return parseNullSeparatedEnv(envDump); +} + +async function resolveInteractiveShellEnv(): Promise { + if (cachedInteractiveShellEnv) { + return cachedInteractiveShellEnv; + } + if (shellEnvResolvePromise) { + return shellEnvResolvePromise; + } + if (process.platform === 'win32') { + cachedInteractiveShellEnv = {}; + return cachedInteractiveShellEnv; + } + + shellEnvResolvePromise = (async () => { + const shellPath = process.env.SHELL || '/bin/zsh'; + try { + const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); + cachedInteractiveShellEnv = loginEnv; + return loginEnv; + } catch (loginError) { + const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); + logger.warn(`Failed to resolve login shell env: ${loginMessage}`); + try { + const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); + cachedInteractiveShellEnv = interactiveEnv; + return interactiveEnv; + } catch (interactiveError) { + const interactiveMessage = + interactiveError instanceof Error ? interactiveError.message : String(interactiveError); + logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); + return {}; + } + } finally { + shellEnvResolvePromise = null; + } + })(); + + return shellEnvResolvePromise; +} + +async function ensureCwdExists(cwd: string): Promise { + await fs.promises.mkdir(cwd, { recursive: true }); + const stat = await fs.promises.stat(cwd); + if (!stat.isDirectory()) { + throw new Error('cwd must be a directory'); + } +} + +function buildMembersPrompt(members: TeamCreateRequest['members']): string { + return members + .map((member) => { + const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; + return `- ${member.name}${rolePart}`; + }) + .join('\n'); +} + +function buildProvisioningPrompt(request: TeamCreateRequest): string { + const displayName = request.displayName?.trim() || request.teamName; + const description = request.description?.trim() || 'No description'; + const members = buildMembersPrompt(request.members); + + return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. +Output must be in English. + +Goal: Provision a Claude Code agent team with live teammates and an initial task. + +Constraints: +- Do NOT call TeamDelete under any circumstances. +- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). +- Do NOT shut down, terminate, or clean up the team or its members. +- Keep assistant text minimal. + +Steps (execute in this exact order): + +1) TeamCreate — create team "${request.teamName}": + - description: "${description}" + +2) Spawn each member as a live teammate using the Task tool: + - team_name: "${request.teamName}" + - name: the member's name + - subagent_type: "general-purpose" + - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Wait for messages from the team lead and respond accordingly. + +MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: +1. Use this command to mark task started: + node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" task start +2. Use this command to mark task completed BEFORE sending your final reply: + node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" task complete +3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): + node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" review approve +4. If review fails and changes are needed: + node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" review request-changes --comment \\"\\" +5. NEVER skip status updates. A task is NOT done until completed status is written. +Failure to follow this protocol means the task board will show incorrect status." + +3) TaskCreate — create 1 initial task: + - subject: "Bootstrap check" + - description: "Confirm team provisioning succeeded. Each member replied OK." + +4) SendMessage to each teammate: + - type: "message" + - summary: "Bootstrap" + - content: "Team \\"${displayName}\\" is ready. Your role: {role}. Reply with 'OK' when you are available." + +5) Wait for all teammates to reply OK, then output a short summary. + +Members: +${members} +`; +} + +function updateProgress( + run: ProvisioningRun, + state: Exclude, + message: string, + extras?: Pick +): TeamProvisioningProgress { + run.progress = { + ...run.progress, + state, + message, + updatedAt: nowIso(), + pid: extras?.pid ?? run.progress.pid, + error: extras?.error, + warnings: extras?.warnings, + cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail, + }; + return run.progress; +} + +function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string { + const stdoutTrimmed = stdoutBuffer.trim(); + const stderrTrimmed = stderrBuffer.trim(); + + if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) { + return ''; + } + if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) { + return stdoutTrimmed; + } + if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) { + return stderrTrimmed; + } + return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n'); +} + +function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined { + const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim(); + if (trimmed.length === 0) { + return undefined; + } + return trimmed.slice(-UI_LOGS_TAIL_LIMIT); +} + +function emitLogsProgress(run: ProvisioningRun): void { + const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer); + if (!logsTail) { + return; + } + run.progress = { + ...run.progress, + updatedAt: nowIso(), + cliLogsTail: logsTail, + }; + run.onProgress(run.progress); +} + +function buildCliExitError(code: number | null, stdoutText: string, stderrText: string): string { + const trimmed = buildCombinedLogs(stdoutText, stderrText).trim(); + if (trimmed.length > 0) { + if (trimmed.toLowerCase().includes('please run /login')) { + return 'CLI output indicates that `-p` mode is not authenticated. `claude -p` typically requires `ANTHROPIC_API_KEY` (Agent SDK). `/login` is interactive-only and does not fix `-p`.'; + } + return trimmed.slice(-4000); + } + + if (code === 1) { + return 'Claude CLI exited with code 1. Typical causes: missing auth/onboarding for CLI, or command requiring interactive TTY. Run `claude` in a normal terminal, complete setup, and retry.'; + } + + return `Claude CLI exited with code ${code ?? 'unknown'}`; +} + +export class TeamProvisioningService { + private readonly runs = new Map(); + private readonly activeByTeam = new Map(); + + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async prepareForProvisioning(cwd?: string): Promise { + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + + const { env: executionEnv, authSource } = await this.buildProvisioningEnv(); + const targetCwd = cwd?.trim() || process.cwd(); + if (!path.isAbsolute(targetCwd)) { + throw new Error('cwd must be an absolute path'); + } + await ensureCwdExists(targetCwd); + + const warnings: string[] = []; + + if (authSource === 'none') { + // No explicit auth found. Still attempt preflight — the CLI may + // authenticate through a mechanism we don't know about (e.g. a + // managed apiKeyHelper, SSO, or a future auth flow). + warnings.push( + 'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN). ' + + 'Attempting preflight check to verify if CLI can authenticate on its own.' + ); + } + + if (authSource === 'anthropic_auth_token') { + warnings.push( + 'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.' + ); + } + if (authSource === 'claude_code_oauth_token_credentials') { + const source = + process.platform === 'darwin' + ? 'macOS Keychain or credentials file' + : `${path.join(getClaudeBasePath(), '.credentials.json')}`; + warnings.push( + `Using OAuth token from ${source}. ` + + 'Note: this token may be stale if Claude Code refreshed it in-memory without persisting. ' + + 'If auth fails, run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN.' + ); + } + + const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); + + if (probe.warning) { + if (authSource === 'none') { + // Preflight also failed — auth is truly missing + return { + ready: false, + message: probe.warning, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + // We had an auth source but preflight still complained — warn but allow + warnings.push(probe.warning); + } + + return { + ready: true, + message: 'CLI is warmed up and ready to launch', + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + + async createTeam( + request: TeamCreateRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + if (this.activeByTeam.has(request.teamName)) { + throw new Error('Provisioning already running'); + } + + const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); + for (const probe of teamsBasePathsToProbe) { + const configPath = path.join(probe.basePath, request.teamName, 'config.json'); + if (await this.pathExists(configPath)) { + const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`; + throw new Error(`Team already exists${suffix}`); + } + } + + await ensureCwdExists(request.cwd); + + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + + const runId = randomUUID(); + const startedAt = nowIso(); + const run: ProvisioningRun = { + runId, + teamName: request.teamName, + startedAt, + stdoutBuffer: '', + stderrBuffer: '', + processKilled: false, + finalizingByTimeout: false, + cancelRequested: false, + teamsBasePathsToProbe, + child: null, + timeoutHandle: null, + fsMonitorHandle: null, + onProgress, + expectedMembers: request.members.map((member) => member.name), + request, + lastLogProgressAt: 0, + waitingTasksSince: null, + provisioningComplete: false, + fsPhase: 'waiting_config', + progress: { + runId, + teamName: request.teamName, + state: 'validating', + message: 'Validating team provisioning request', + startedAt, + updatedAt: startedAt, + cliLogsTail: undefined, + }, + }; + + this.runs.set(runId, run); + this.activeByTeam.set(request.teamName, runId); + run.onProgress(run.progress); + + const prompt = buildProvisioningPrompt(request); + let child: ReturnType; + const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); + if (authSource === 'none') { + logger.warn( + 'No explicit auth env var found for `-p` mode. ' + + 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' + ); + } + try { + child = spawn( + claudePath, + [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--setting-sources', + 'user,project,local', + '--disallowedTools', + 'TeamDelete,TodoWrite', + ], + { + cwd: request.cwd, + env: { + ...shellEnv, + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + } catch (error) { + this.runs.delete(runId); + this.activeByTeam.delete(request.teamName); + throw error; + } + + updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined }); + run.onProgress(run.progress); + run.child = child; + + // Send provisioning prompt as first stream-json message (SDKUserMessage format) + if (child.stdin) { + const message = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: prompt }], + }, + }); + child.stdin.write(message + '\n'); + } + + if (child.stdout) { + let stdoutLineBuf = ''; + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8'); + run.stdoutBuffer += text; + if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { + run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT); + } + + // Parse stream-json lines (newline-delimited JSON) + stdoutLineBuf += text; + const lines = stdoutLineBuf.split('\n'); + stdoutLineBuf = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const msg = JSON.parse(trimmed) as Record; + this.handleStreamJsonMessage(run, msg); + } catch { + // Not valid JSON — raw text output, ignore + } + } + + const currentTs = Date.now(); + if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { + run.lastLogProgressAt = currentTs; + emitLogsProgress(run); + } + }); + } + + if (child.stderr) { + child.stderr.on('data', (chunk: Buffer) => { + run.stderrBuffer += chunk.toString('utf8'); + if (run.stderrBuffer.length > STDERR_RING_LIMIT) { + run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT); + } + const currentTs = Date.now(); + if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { + run.lastLogProgressAt = currentTs; + emitLogsProgress(run); + } + }); + } + + // Filesystem-based progress monitor: actively polls team files instead + // of relying on stdout (which only arrives at the end in text mode). + // When config + members + tasks are all present, kill the process early + // rather than waiting for it to deadlock on system-reminder shutdown. + this.startFilesystemMonitor(run, request); + + run.timeoutHandle = setTimeout(() => { + if (!run.processKilled && !run.provisioningComplete) { + run.processKilled = true; + run.finalizingByTimeout = true; + void (async () => { + const readyOnTimeout = await this.tryCompleteAfterTimeout(run); + run.child?.stdin?.end(); + run.child?.kill(); + if (readyOnTimeout) { + return; // cleanupRun already called inside tryCompleteAfterTimeout + } + + const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI', { + error: + 'Timed out waiting for CLI. Run `claude` once in terminal to complete onboarding and try again.', + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + })(); + } + }, RUN_TIMEOUT_MS); + + child.once('error', (error) => { + const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI', { + error: error.message, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + }); + + child.once('exit', (code) => { + void this.handleProcessExit(run, code); + }); + + return { runId }; + } + + async getProvisioningStatus(runId: string): Promise { + const run = this.runs.get(runId); + if (!run) { + throw new Error('Unknown runId'); + } + return run.progress; + } + + async cancelProvisioning(runId: string): Promise { + const run = this.runs.get(runId); + if (!run) { + throw new Error('Unknown runId'); + } + if (!['spawning', 'monitoring', 'verifying'].includes(run.progress.state)) { + throw new Error('Provisioning cannot be cancelled in current state'); + } + + run.cancelRequested = true; + run.processKilled = true; + run.child?.stdin?.end(); + run.child?.kill(); + const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); + run.onProgress(progress); + this.cleanupRun(run); + } + + /** + * Send a message to the team's lead process via stream-json stdin. + * The lead will receive it as a new user turn and can delegate to teammates. + */ + async sendMessageToTeam(teamName: string, message: string): Promise { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + throw new Error(`No active process for team "${teamName}"`); + } + const run = this.runs.get(runId); + if (!run?.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + const payload = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: message }], + }, + }); + run.child.stdin.write(payload + '\n'); + } + + /** + * Check if a team has a live process. + */ + isTeamAlive(teamName: string): boolean { + const runId = this.activeByTeam.get(teamName); + if (!runId) return false; + const run = this.runs.get(runId); + return run?.child != null && !run.processKilled && !run.cancelRequested; + } + + /** + * Get list of teams with active processes. + */ + getAliveTeams(): string[] { + return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name)); + } + + /** + * Process a parsed stream-json message from stdout. + * Extracts assistant text for progress reporting and detects turn completion. + */ + private handleStreamJsonMessage(run: ProvisioningRun, msg: Record): void { + // stream-json output has various message types: + // {"type":"assistant","content":[{"type":"text","text":"..."},...]} + // {"type":"result","subtype":"success",...} + if (msg.type === 'assistant' && Array.isArray(msg.content)) { + const textParts = (msg.content as Record[]) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text as string); + if (textParts.length > 0) { + const text = textParts.join(''); + logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); + } + } + + if (msg.type === 'result') { + const subtype = msg.subtype as string | undefined; + if (subtype === 'success') { + logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); + if (!run.provisioningComplete) { + void this.handleProvisioningTurnComplete(run); + } + } else if (subtype === 'error') { + const errorMsg = + typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown'); + logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`); + if (!run.provisioningComplete) { + const progress = updateProgress( + run, + 'failed', + 'CLI reported an error during provisioning', + { + error: errorMsg, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + } + ); + run.onProgress(progress); + // Kill the process on provisioning error + run.processKilled = true; + run.child?.stdin?.end(); + run.child?.kill(); + this.cleanupRun(run); + } + } + } + } + + /** + * Called when the first stream-json turn completes successfully. + * Verifies provisioning files exist and marks as ready. + * Process stays alive for subsequent tasks. + */ + private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { + run.provisioningComplete = true; + + // Clear provisioning timeout — no longer needed + if (run.timeoutHandle) { + clearTimeout(run.timeoutHandle); + run.timeoutHandle = null; + } + this.stopFilesystemMonitor(run); + + // Quick verification: config should exist by now + const configProbe = await this.waitForValidConfig(run, 5000); + if (!configProbe.ok) { + logger.warn( + `[${run.teamName}] Provisioning turn completed but no config.json found — marking ready anyway` + ); + } + + if (configProbe.ok && configProbe.location === 'default') { + const configuredTeamsBasePath = getTeamsBasePath(); + const progress = updateProgress(run, 'failed', 'Provisioning failed validation', { + error: + `TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` + + `This app is configured to read teams from ${configuredTeamsBasePath}. ` + + 'Align the app Claude root setting with the CLI, then retry.', + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + run.processKilled = true; + run.child?.stdin?.end(); + run.child?.kill(); + this.cleanupRun(run); + return; + } + + // Patch config with expected members (fallback if CLI didn't register all) + await this.patchConfigWithExpectedMembers(run.teamName, run.request); + + const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + // NOTE: do NOT remove from activeByTeam — process stays alive + logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + } + + /** + * Remove a run from tracking maps. + */ + private cleanupRun(run: ProvisioningRun): void { + if (run.timeoutHandle) { + clearTimeout(run.timeoutHandle); + run.timeoutHandle = null; + } + this.stopFilesystemMonitor(run); + this.activeByTeam.delete(run.teamName); + } + + /** + * Polls the filesystem to track provisioning progress in real time. + * Emits progress updates as team files appear (config, inboxes, tasks). + */ + private startFilesystemMonitor(run: ProvisioningRun, request: TeamCreateRequest): void { + const configuredTeamDir = path.join(getTeamsBasePath(), run.teamName); + const defaultTeamDir = path.join(getAutoDetectedClaudeBasePath(), 'teams', run.teamName); + const tasksDir = path.join(getTasksBasePath(), run.teamName); + + const resolveTeamDir = async (): Promise => { + const configPath = path.join(configuredTeamDir, 'config.json'); + try { + await fs.promises.access(configPath, fs.constants.F_OK); + return configuredTeamDir; + } catch { + // fallback to default location + } + if (path.resolve(configuredTeamDir) !== path.resolve(defaultTeamDir)) { + const defaultConfigPath = path.join(defaultTeamDir, 'config.json'); + try { + await fs.promises.access(defaultConfigPath, fs.constants.F_OK); + return defaultTeamDir; + } catch { + // not found in either location + } + } + return null; + }; + + const countFiles = async (dir: string, ext: string): Promise => { + try { + const entries = await fs.promises.readdir(dir); + return entries.filter((e) => e.endsWith(ext) && !e.startsWith('.')).length; + } catch { + return 0; + } + }; + + const poll = async (): Promise => { + if (run.cancelRequested || run.processKilled || run.progress.state === 'ready') { + return; + } + + try { + if (run.fsPhase === 'waiting_config') { + const teamDir = await resolveTeamDir(); + if (teamDir) { + run.fsPhase = 'waiting_members'; + const progress = updateProgress( + run, + 'monitoring', + 'Team config created, waiting for members' + ); + run.onProgress(progress); + } + } + + if (run.fsPhase === 'waiting_members') { + const teamDir = (await resolveTeamDir()) ?? configuredTeamDir; + const inboxDir = path.join(teamDir, 'inboxes'); + const inboxCount = await countFiles(inboxDir, '.json'); + if (inboxCount >= request.members.length) { + run.fsPhase = 'waiting_tasks'; + const progress = updateProgress( + run, + 'monitoring', + `All ${inboxCount} member inboxes created, waiting for tasks` + ); + run.onProgress(progress); + } else if (inboxCount > 0) { + const progress = updateProgress( + run, + 'monitoring', + `${inboxCount}/${request.members.length} member inboxes created` + ); + run.onProgress(progress); + } + } + + if (run.fsPhase === 'waiting_tasks') { + if (run.waitingTasksSince === null) { + run.waitingTasksSince = Date.now(); + } + const taskCount = await countFiles(tasksDir, '.json'); + const taskFound = taskCount > 0; + const taskFallbackExpired = + !taskFound && Date.now() - run.waitingTasksSince >= TASK_WAIT_FALLBACK_MS; + + if (taskFound || taskFallbackExpired) { + run.fsPhase = 'all_files_found'; + const message = taskFound + ? `Team provisioned: ${taskCount} task(s). Teammates working...` + : 'Team provisioned (no task file yet). Teammates working...'; + const progress = updateProgress(run, 'monitoring', message); + run.onProgress(progress); + // No early-kill — let the process run naturally. + // The lead will finish when teammates complete their work, + // then system-reminder fires, --disallowedTools blocks TeamDelete, + // and the process exits on its own. + } + } + } catch (error) { + logger.debug( + `FS monitor poll error: ${error instanceof Error ? error.message : String(error)}` + ); + } + }; + + run.fsMonitorHandle = setInterval(() => { + void poll(); + }, FS_MONITOR_POLL_MS); + + // Run first poll immediately + void poll(); + } + + private stopFilesystemMonitor(run: ProvisioningRun): void { + if (run.fsMonitorHandle) { + clearInterval(run.fsMonitorHandle); + run.fsMonitorHandle = null; + } + } + + private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise { + if (run.finalizingByTimeout) { + return; + } + if (run.progress.state === 'failed' || run.cancelRequested) { + return; + } + + // === Process exited AFTER provisioning completed === + // This means the team went offline (crash, kill, or natural exit). + if (run.provisioningComplete) { + const message = + code === 0 + ? 'Team process exited normally' + : `Team process exited unexpectedly (code ${code ?? 'unknown'})`; + logger.info(`[${run.teamName}] ${message}`); + const progress = updateProgress(run, 'disconnected', message, { + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + return; + } + + // === Process exited DURING provisioning === + // Try to verify if files were created before the process died. + updateProgress(run, 'verifying', 'Process exited — verifying provisioning results'); + run.onProgress(run.progress); + + if (run.cancelRequested) { + return; + } + + const configProbe = await this.waitForValidConfig(run); + if (run.cancelRequested) { + return; + } + + if (configProbe.ok && configProbe.location === 'default') { + const configuredTeamsBasePath = getTeamsBasePath(); + const progress = updateProgress(run, 'failed', 'Provisioning failed validation', { + error: + `TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` + + `This app is configured to read teams from ${configuredTeamsBasePath}. ` + + 'Align the app Claude root setting with the CLI, then retry.', + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + return; + } + + const visibleInList = + configProbe.ok && configProbe.location === 'configured' + ? await this.waitForTeamInList(run.teamName, run) + : false; + if (run.cancelRequested) { + return; + } + + if (configProbe.ok && visibleInList) { + // Files exist but process died — provisioned but not alive. + const warnings: string[] = [ + `CLI process exited (code ${code ?? 'unknown'}) — team provisioned but not alive`, + ]; + const missingInboxes = await this.waitForMissingInboxes(run); + if (run.cancelRequested) { + return; + } + if (missingInboxes.length > 0) { + warnings.push('Some inboxes not created yet'); + } + await this.patchConfigWithExpectedMembers(run.teamName, run.request); + // Mark as disconnected since the process is dead + const progress = updateProgress( + run, + 'disconnected', + 'Team provisioned but process is no longer alive', + { + warnings, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + } + ); + run.onProgress(progress); + this.cleanupRun(run); + return; + } + + if (code === 0) { + const configuredConfigPath = path.join(getTeamsBasePath(), run.teamName, 'config.json'); + const defaultTeamsBasePath = path.join(getAutoDetectedClaudeBasePath(), 'teams'); + const defaultConfigPath = path.join(defaultTeamsBasePath, run.teamName, 'config.json'); + const combinedLogs = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer); + const cleanupHint = logsSuggestShutdownOrCleanup(combinedLogs) + ? ' CLI output suggests the team was shut down / cleaned up, so no persisted config was left on disk.' + : ''; + + const errorMessage = !configProbe.ok + ? `No valid config.json found at ${configuredConfigPath}${ + path.resolve(defaultTeamsBasePath) === path.resolve(getTeamsBasePath()) + ? '' + : ` (also checked ${defaultConfigPath})` + } within ${Math.round(VERIFY_TIMEOUT_MS / 1000)}s.${cleanupHint}` + : 'Team did not appear in team:list after provisioning'; + const progress = updateProgress(run, 'failed', 'Provisioning failed validation', { + error: errorMessage, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + return; + } + + const errorText = buildCliExitError(code, run.stdoutBuffer, run.stderrBuffer); + const progress = updateProgress(run, 'failed', 'Claude CLI exited with an error', { + error: errorText, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + logger.warn(`Provisioning failed for ${run.teamName}: ${progress.error ?? errorText}`); + } + + private async waitForValidConfig( + run: ProvisioningRun, + timeoutMs: number = VERIFY_TIMEOUT_MS + ): Promise { + const probes = run.teamsBasePathsToProbe.map((probe) => ({ + ...probe, + configPath: path.join(probe.basePath, run.teamName, 'config.json'), + })); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (run.cancelRequested) { + return { ok: false }; + } + for (const probe of probes) { + try { + const raw = await fs.promises.readFile(probe.configPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object') { + const candidate = parsed as { name?: unknown }; + if (typeof candidate.name === 'string' && candidate.name.trim().length > 0) { + return { ok: true, location: probe.location, configPath: probe.configPath }; + } + } + } catch { + // Best-effort polling until deadline. + } + } + await sleep(VERIFY_POLL_MS); + } + + return { ok: false }; + } + + private async waitForTeamInList(teamName: string, run?: ProvisioningRun): Promise { + const deadline = Date.now() + VERIFY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (run?.cancelRequested) { + return false; + } + try { + const teams = await this.configReader.listTeams(); + if (teams.some((team) => team.teamName === teamName)) { + return true; + } + } catch { + // Keep polling until deadline. + } + await sleep(VERIFY_POLL_MS); + } + return false; + } + + private async waitForMissingInboxes(run: ProvisioningRun): Promise { + if (run.expectedMembers.length === 0) { + return []; + } + const inboxDir = path.join(getTeamsBasePath(), run.teamName, 'inboxes'); + const deadline = Date.now() + VERIFY_TIMEOUT_MS; + let missing = new Set(run.expectedMembers); + + while (Date.now() < deadline && missing.size > 0) { + if (run.cancelRequested || run.progress.state === 'cancelled') { + return Array.from(missing); + } + const nextMissing = new Set(); + for (const member of missing) { + const inboxPath = path.join(inboxDir, `${member}.json`); + if (!(await this.pathExists(inboxPath))) { + nextMissing.add(member); + } + } + missing = nextMissing; + if (missing.size === 0) { + break; + } + await sleep(VERIFY_POLL_MS); + } + + return Array.from(missing); + } + + private async tryCompleteAfterTimeout(run: ProvisioningRun): Promise { + if (run.cancelRequested) { + return false; + } + + const configProbe = await this.waitForValidConfig(run); + if (!configProbe.ok || configProbe.location !== 'configured') { + return false; + } + + const visibleInList = await this.waitForTeamInList(run.teamName); + if (!visibleInList) { + return false; + } + + const warnings: string[] = [ + 'CLI timed out after config was created — team provisioned but process killed', + ]; + const missingInboxes = await this.waitForMissingInboxes(run); + if (run.cancelRequested) { + return false; + } + if (missingInboxes.length > 0) { + warnings.push('Some inboxes not created yet'); + } + + await this.patchConfigWithExpectedMembers(run.teamName, run.request); + // Process was killed by timeout — mark as disconnected, not ready + const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', { + warnings, + }); + run.onProgress(progress); + this.cleanupRun(run); + return true; + } + + private async pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } + } + + private async buildProvisioningEnv(): Promise { + const shellEnv = await resolveInteractiveShellEnv(); + const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || os.homedir(); + const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; + const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; + const xdgConfigHome = + shellEnv.XDG_CONFIG_HOME?.trim() || process.env.XDG_CONFIG_HOME?.trim() || `${home}/.config`; + const xdgStateHome = + shellEnv.XDG_STATE_HOME?.trim() || + process.env.XDG_STATE_HOME?.trim() || + `${home}/.local/state`; + + const env: NodeJS.ProcessEnv = { + ...process.env, + ...shellEnv, + HOME: home, + USER: user, + LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, + SHELL: shell, + TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color', + XDG_CONFIG_HOME: xdgConfigHome, + XDG_STATE_HOME: xdgStateHome, + // 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(), + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + }; + + // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly + if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { + return { env, authSource: 'anthropic_api_key' }; + } + + // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, + // so we must copy it into ANTHROPIC_API_KEY for it to work. + if ( + typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && + env.ANTHROPIC_AUTH_TOKEN.trim().length > 0 + ) { + env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN; + return { env, authSource: 'anthropic_auth_token' }; + } + + // 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`) + if ( + typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' && + env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0 + ) { + return { env, authSource: 'claude_code_oauth_token_env' }; + } + + // 4. Try reading OAuth token from platform credential storage. + // macOS: Keychain (service "Claude Code-credentials") + // Linux: ~/.claude/.credentials.json + // Note: keychain tokens may be stale — Claude Code refreshes in-memory + // but does not always write back. We still try as best-effort. + const oauthToken = await this.readOAuthTokenFromStorage(home); + if (oauthToken) { + env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken; + return { env, authSource: 'claude_code_oauth_token_credentials' }; + } + + return { env, authSource: 'none' }; + } + + /** + * Attempts to read the OAuth access token from platform-specific storage. + * + * On macOS: reads from the encrypted Keychain (service "Claude Code-credentials"). + * On Linux: reads from ~/.claude/.credentials.json. + * + * Warning: the token retrieved here may be expired. Claude Code refreshes + * tokens in-memory but does not always persist the refreshed value back to + * the credential store. A subsequent preflight check (`claude -p "ping"`) + * will detect if the token is actually usable. + */ + private async readOAuthTokenFromStorage(home: string): Promise { + const claudeBasePath = getClaudeBasePath(); + if (process.platform === 'darwin') { + const keychainToken = await this.readOAuthTokenFromKeychain(); + if (keychainToken) { + return keychainToken; + } + // Fallback: ~/.claude/.credentials.json (or overridden Claude root) + return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home); + } + return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home); + } + + private async readOAuthTokenFromKeychain(): Promise { + try { + const { stdout } = await execFileAsync( + 'security', + ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], + { timeout: KEYCHAIN_TIMEOUT_MS } + ); + const parsed = JSON.parse(stdout.trim()) as unknown; + return this.extractOAuthAccessToken(parsed); + } catch { + return null; + } + } + + private async readOAuthTokenFromCredentialsFile( + claudeBasePath: string, + homeFallback: string + ): Promise { + // Preferred: current Claude root (supports claudeRootPath override) + const primaryPath = path.join(claudeBasePath, '.credentials.json'); + // Back-compat: legacy location under HOME + const legacyPath = path.join(homeFallback, '.claude', '.credentials.json'); + try { + const raw = await fs.promises.readFile(primaryPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return this.extractOAuthAccessToken(parsed); + } catch { + try { + const raw = await fs.promises.readFile(legacyPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return this.extractOAuthAccessToken(parsed); + } catch { + return null; + } + } + } + + private extractOAuthAccessToken(parsed: unknown): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + const root = parsed as { claudeAiOauth?: unknown }; + if (!root.claudeAiOauth || typeof root.claudeAiOauth !== 'object') { + return null; + } + const oauth = root.claudeAiOauth as { accessToken?: unknown }; + if (typeof oauth.accessToken !== 'string') { + return null; + } + const token = oauth.accessToken.trim(); + return token.length > 0 ? token : null; + } + + /** + * After the CLI creates config.json (with only team-lead), patch in the + * expected members from the provisioning request. The simplified prompt + * sends bootstrap messages to inboxes but does not spawn actual teammate + * processes, so the members array would otherwise only contain the lead. + */ + private async patchConfigWithExpectedMembers( + teamName: string, + request: TeamCreateRequest + ): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config = JSON.parse(raw) as Record; + const existingMembers = Array.isArray(config.members) + ? (config.members as Record[]) + : []; + + const existingNames = new Set( + existingMembers.filter((m) => typeof m.name === 'string').map((m) => m.name as string) + ); + + const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red']; + let colorIdx = 0; + + for (const member of request.members) { + if (existingNames.has(member.name)) { + continue; + } + + existingMembers.push({ + agentId: `${member.name}@${teamName}`, + name: member.name, + agentType: 'general-purpose', + role: member.role?.trim() || undefined, + color: memberColors[colorIdx % memberColors.length], + joinedAt: Date.now(), + tmuxPaneId: '', + cwd: request.cwd, + subscriptions: [], + }); + colorIdx++; + } + + config.members = existingMembers; + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + logger.info( + `Patched config.json for ${teamName}: added ${request.members.length - (existingNames.size - 1)} members` + ); + } catch (error) { + logger.warn( + `Failed to patch config.json with members: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Two-stage preflight check: + * 1. `claude --version` — verifies binary is executable and returns version info. + * 2. `claude -p "ping"` — verifies that `-p` mode is actually authenticated. + * This catches the common case where interactive `claude` works (OAuth/keychain) + * but `-p` mode fails with "Not logged in" due to missing env vars. + */ + private async probeClaudeRuntime( + claudePath: string, + cwd: string, + env: NodeJS.ProcessEnv + ): Promise<{ warning?: string }> { + // Stage 1: verify binary works + const versionProbe = await this.spawnProbe( + claudePath, + ['--version'], + cwd, + env, + CLI_PREPARE_TIMEOUT_MS + ); + if (versionProbe.exitCode !== 0) { + const errorText = + buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || + `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + throw new Error(`Failed to warm up Claude CLI: ${errorText}`); + } + + // Stage 2: verify `-p` mode auth actually works + const pingProbe = await this.spawnProbe( + claudePath, + ['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'], + cwd, + env, + PREFLIGHT_TIMEOUT_MS + ); + + const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr); + const lowerOutput = combinedOutput.toLowerCase(); + const isAuthFailure = + lowerOutput.includes('not logged in') || + lowerOutput.includes('please run /login') || + lowerOutput.includes('missing api key') || + lowerOutput.includes('invalid api key'); + + if (isAuthFailure || pingProbe.exitCode !== 0) { + const hint = isAuthFailure + ? 'Claude CLI `-p` mode is not authenticated. ' + + 'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' + + 'then export it as CLAUDE_CODE_OAUTH_TOKEN.' + : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; + return { warning: hint }; + } + + return {}; + } + + private async spawnProbe( + claudePath: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, + timeoutMs: number + ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(claudePath, args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const timeoutHandle = setTimeout(() => { + child.kill(); + reject(new Error(`Timeout running: claude ${args.join(' ')}`)); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + child.once('error', (error) => { + clearTimeout(timeoutHandle); + reject(error); + }); + child.once('close', (exitCode) => { + clearTimeout(timeoutHandle); + resolve({ + exitCode, + stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(), + stderr: Buffer.concat(stderrChunks).toString('utf8').trim(), + }); + }); + }); + } +} +/* eslint-enable no-param-reassign -- Re-enable after TeamProvisioningService class */ diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts new file mode 100644 index 00000000..617c81c3 --- /dev/null +++ b/src/main/services/team/TeamTaskReader.ts @@ -0,0 +1,105 @@ +import { getTasksBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { TeamTask } from '@shared/types'; + +const logger = createLogger('Service:TeamTaskReader'); + +export class TeamTaskReader { + /** + * Returns the next available numeric task ID by scanning ALL task files + * (including _internal ones) to avoid ID collisions. + */ + async getNextTaskId(teamName: string): Promise { + const tasksDir = path.join(getTasksBasePath(), teamName); + + let entries: string[]; + try { + entries = await fs.promises.readdir(tasksDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return '1'; + } + throw error; + } + + let maxId = 0; + for (const file of entries) { + if (!file.endsWith('.json')) continue; + const num = Number(file.replace('.json', '')); + if (Number.isFinite(num) && num > maxId) { + maxId = num; + } + } + + return String(maxId + 1); + } + + async getTasks(teamName: string): Promise { + const tasksDir = path.join(getTasksBasePath(), teamName); + + let entries: string[]; + try { + entries = await fs.promises.readdir(tasksDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const tasks: TeamTask[] = []; + for (const file of entries) { + if ( + !file.endsWith('.json') || + file.startsWith('.') || + file === '.lock' || + file === '.highwatermark' + ) { + continue; + } + + const taskPath = path.join(tasksDir, file); + try { + const raw = await fs.promises.readFile(taskPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + // Skip internal CLI tracking entries (spawned subagent bookkeeping) + const metadata = parsed.metadata as Record | undefined; + if (metadata?._internal === true) { + continue; + } + // CLI sometimes writes "title" instead of "subject" — normalize + const subject = + typeof parsed.subject === 'string' + ? parsed.subject + : typeof parsed.title === 'string' + ? parsed.title + : ''; + const task: TeamTask = { + id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', + subject, + description: typeof parsed.description === 'string' ? parsed.description : undefined, + activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, + status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( + parsed.status as TeamTask['status'] + ) + ? (parsed.status as TeamTask['status']) + : 'pending', + blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, + blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, + }; + if (task.status === 'deleted') { + continue; + } + tasks.push(task); + } catch { + logger.debug(`Skipping invalid task file: ${taskPath}`); + } + } + + return tasks; + } +} diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts new file mode 100644 index 00000000..86f7b43c --- /dev/null +++ b/src/main/services/team/TeamTaskWriter.ts @@ -0,0 +1,90 @@ +import { getTasksBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from './atomicWrite'; + +import type { TeamTask, TeamTaskStatus } from '@shared/types'; + +export class TeamTaskWriter { + async createTask(teamName: string, task: TeamTask): Promise { + const tasksDir = path.join(getTasksBasePath(), teamName); + await fs.promises.mkdir(tasksDir, { recursive: true }); + + const taskPath = path.join(tasksDir, `${task.id}.json`); + try { + await fs.promises.access(taskPath, fs.constants.F_OK); + throw new Error(`Task already exists: ${task.id}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + // Ensure CLI-compatible format: description, blocks, blockedBy are required + // by Claude Code CLI's Zod schema validation (safeParse fails without them) + const cliCompatibleTask: TeamTask = { + ...task, + description: task.description ?? '', + blocks: task.blocks ?? [], + blockedBy: task.blockedBy ?? [], + }; + + await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2)); + + const verifyRaw = await fs.promises.readFile(taskPath, 'utf8'); + const verifyTask = JSON.parse(verifyRaw) as TeamTask; + if (verifyTask.id !== task.id) { + throw new Error(`Task create verification failed: ${task.id}`); + } + } + + async addBlocksEntry( + teamName: string, + targetTaskId: string, + blockedTaskId: string + ): Promise { + const taskPath = path.join(getTasksBasePath(), teamName, `${targetTaskId}.json`); + + let raw: string; + try { + raw = await fs.promises.readFile(taskPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; // Target task doesn't exist — skip silently + } + throw error; + } + + const task = JSON.parse(raw) as TeamTask; + const blocks = task.blocks ?? []; + if (!blocks.includes(blockedTaskId)) { + task.blocks = [...blocks, blockedTaskId]; + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + } + } + + async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise { + const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); + + let raw: string; + try { + raw = await fs.promises.readFile(taskPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Task not found: ${taskId}`); + } + throw error; + } + + const task = JSON.parse(raw) as TeamTask; + task.status = status; + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + + const verifyRaw = await fs.promises.readFile(taskPath, 'utf8'); + const verifyTask = JSON.parse(verifyRaw) as TeamTask; + if (verifyTask.status !== status) { + throw new Error(`Task status update verification failed: ${taskId}`); + } + } +} diff --git a/src/main/services/team/atomicWrite.ts b/src/main/services/team/atomicWrite.ts new file mode 100644 index 00000000..500c038c --- /dev/null +++ b/src/main/services/team/atomicWrite.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Async atomic write: write tmp file then rename over target. + * Uses best-effort fsync and EXDEV fallback for safety. + */ +export async function atomicWriteAsync(targetPath: string, data: string): Promise { + const dir = path.dirname(targetPath); + const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); + + try { + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(tmpPath, data, 'utf8'); + + try { + const fd = await fs.promises.open(tmpPath, 'r+'); + await fd.sync(); + await fd.close(); + } catch { + // fsync is best-effort. + } + + try { + await fs.promises.rename(tmpPath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + await fs.promises.copyFile(tmpPath, targetPath); + await fs.promises.unlink(tmpPath).catch(() => undefined); + } else { + throw error; + } + } + } catch (error) { + await fs.promises.unlink(tmpPath).catch(() => undefined); + throw error; + } +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 769f78a2..1a3f6b75 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,2 +1,11 @@ +export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; export { TeamConfigReader } from './TeamConfigReader'; export { TeamDataService } from './TeamDataService'; +export { TeamInboxReader } from './TeamInboxReader'; +export { TeamInboxWriter } from './TeamInboxWriter'; +export { TeamKanbanManager } from './TeamKanbanManager'; +export { TeamMemberResolver } from './TeamMemberResolver'; +export { TeamProvisioningService } from './TeamProvisioningService'; +export { TeamTaskReader } from './TeamTaskReader'; +export { TeamTaskWriter } from './TeamTaskWriter'; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 84f34da4..2fab97e5 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -314,3 +314,10 @@ export function getTodosBasePath(): string { export function getTeamsBasePath(): string { return path.join(getClaudeBasePath(), 'teams'); } + +/** + * Get the tasks directory path (~/.claude/tasks). + */ +export function getTasksBasePath(): string { + return path.join(getClaudeBasePath(), 'tasks'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 5756a313..73db8fd2 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -178,3 +178,51 @@ export const WINDOW_IS_MAXIMIZED = 'window:isMaximized'; /** List all teams */ export const TEAM_LIST = 'team:list'; + +/** Get detailed team data */ +export const TEAM_GET_DATA = 'team:getData'; + +/** Update team kanban state */ +export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; + +/** Send inbox message to team member */ +export const TEAM_SEND_MESSAGE = 'team:sendMessage'; + +/** Request review for task */ +export const TEAM_REQUEST_REVIEW = 'team:requestReview'; + +/** Team change events (main -> renderer) */ +export const TEAM_CHANGE = 'team:change'; + +/** Create new team by provisioning through CLI */ +export const TEAM_CREATE = 'team:create'; + +/** Warm up provisioning runtime before create */ +export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning'; + +/** Get provisioning status by runId */ +export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'; + +/** Cancel running provisioning by runId */ +export const TEAM_CANCEL_PROVISIONING = 'team:cancelProvisioning'; + +/** Team provisioning progress events (main -> renderer) */ +export const TEAM_PROVISIONING_PROGRESS = 'team:provisioningProgress'; + +/** Send message to team's live CLI process via stream-json stdin */ +export const TEAM_PROCESS_SEND = 'team:processSend'; + +/** Check if team has a live CLI process */ +export const TEAM_PROCESS_ALIVE = 'team:processAlive'; + +/** Create a task in team's task directory */ +export const TEAM_CREATE_TASK = 'team:createTask'; + +/** Update task status directly (pending/in_progress/completed) */ +export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus'; + +/** Delete a team and its associated task directory */ +export const TEAM_DELETE_TEAM = 'team:deleteTeam'; + +/** Get list of teams with live CLI processes */ +export const TEAM_ALIVE_LIST = 'team:aliveList'; diff --git a/src/preload/index.ts b/src/preload/index.ts index b461a391..257c2a75 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,7 +18,23 @@ import { SSH_SAVE_LAST_CONNECTION, SSH_STATUS, SSH_TEST, + TEAM_ALIVE_LIST, + TEAM_CANCEL_PROVISIONING, + TEAM_CHANGE, + TEAM_CREATE, + TEAM_CREATE_TASK, + TEAM_DELETE_TEAM, + TEAM_GET_DATA, TEAM_LIST, + TEAM_PREPARE_PROVISIONING, + TEAM_PROCESS_ALIVE, + TEAM_PROCESS_SEND, + TEAM_PROVISIONING_PROGRESS, + TEAM_PROVISIONING_STATUS, + TEAM_REQUEST_REVIEW, + TEAM_SEND_MESSAGE, + TEAM_UPDATE_KANBAN, + TEAM_UPDATE_TASK_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -60,18 +76,30 @@ import type { ClaudeRootFolderSelection, ClaudeRootInfo, ContextInfo, + CreateTaskRequest, ElectronAPI, HttpServerStatus, + IpcResult, NotificationTrigger, + SendMessageRequest, + SendMessageResult, SessionsByIdsOptions, SessionsPaginationOptions, SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, SshLastConnection, + TeamChangeEvent, + TeamCreateRequest, + TeamCreateResponse, + TeamData, + TeamProvisioningPrepareResult, + TeamProvisioningProgress, TeamSummary, + TeamTask, + TeamTaskStatus, TriggerTestResult, - IpcResult, + UpdateKanbanPatch, WslClaudeRootCandidate, } from '@shared/types'; @@ -456,6 +484,74 @@ const electronAPI: ElectronAPI = { list: async () => { return invokeIpcWithResult(TEAM_LIST); }, + getData: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_DATA, teamName); + }, + deleteTeam: async (teamName: string) => { + return invokeIpcWithResult(TEAM_DELETE_TEAM, teamName); + }, + prepareProvisioning: async (cwd?: string) => { + return invokeIpcWithResult(TEAM_PREPARE_PROVISIONING, cwd); + }, + createTeam: async (request: TeamCreateRequest) => { + return invokeIpcWithResult(TEAM_CREATE, request); + }, + getProvisioningStatus: async (runId: string) => { + return invokeIpcWithResult(TEAM_PROVISIONING_STATUS, runId); + }, + cancelProvisioning: async (runId: string) => { + return invokeIpcWithResult(TEAM_CANCEL_PROVISIONING, runId); + }, + sendMessage: async (teamName: string, request: SendMessageRequest) => { + return invokeIpcWithResult(TEAM_SEND_MESSAGE, teamName, request); + }, + createTask: async (teamName: string, request: CreateTaskRequest) => { + return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request); + }, + requestReview: async (teamName: string, taskId: string) => { + return invokeIpcWithResult(TEAM_REQUEST_REVIEW, teamName, taskId); + }, + updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => { + return invokeIpcWithResult(TEAM_UPDATE_KANBAN, teamName, taskId, patch); + }, + updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { + return invokeIpcWithResult(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status); + }, + processSend: async (teamName: string, message: string) => { + return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); + }, + processAlive: async (teamName: string) => { + return invokeIpcWithResult(TEAM_PROCESS_ALIVE, teamName); + }, + aliveList: async () => { + return invokeIpcWithResult(TEAM_ALIVE_LIST); + }, + onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { + ipcRenderer.on( + TEAM_CHANGE, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TEAM_CHANGE, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + onProvisioningProgress: ( + callback: (event: unknown, data: TeamProvisioningProgress) => void + ): (() => void) => { + ipcRenderer.on( + TEAM_PROVISIONING_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TEAM_PROVISIONING_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, }, }; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 666170fe..95449318 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -14,6 +14,7 @@ import type { ConfigAPI, ContextInfo, ConversationGroup, + CreateTaskRequest, ElectronAPI, FileChangeEvent, HttpServerAPI, @@ -24,6 +25,8 @@ import type { Project, RepositoryGroup, SearchSessionsResult, + SendMessageRequest, + SendMessageResult, Session, SessionAPI, SessionDetail, @@ -36,9 +39,18 @@ import type { SshConnectionStatus, SshLastConnection, SubagentDetail, - TeamSummary, + TeamChangeEvent, + TeamCreateRequest, + TeamCreateResponse, + TeamData, + TeamProvisioningPrepareResult, + TeamProvisioningProgress, TeamsAPI, + TeamSummary, + TeamTask, + TeamTaskStatus, TriggerTestResult, + UpdateKanbanPatch, UpdaterAPI, WaterfallData, WslClaudeRootCandidate, @@ -592,5 +604,68 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, + getData: async (_teamName: string): Promise => { + throw new Error('Teams detail is not available in browser mode'); + }, + deleteTeam: async (_teamName: string): Promise => { + throw new Error('Team deletion is not available in browser mode'); + }, + prepareProvisioning: async (_cwd?: string): Promise => { + throw new Error('Team provisioning is not available in browser mode'); + }, + createTeam: async (_request: TeamCreateRequest): Promise => { + throw new Error('Team provisioning is not available in browser mode'); + }, + getProvisioningStatus: async (_runId: string): Promise => { + throw new Error('Team provisioning is not available in browser mode'); + }, + cancelProvisioning: async (_runId: string): Promise => { + throw new Error('Team provisioning is not available in browser mode'); + }, + sendMessage: async ( + _teamName: string, + _request: SendMessageRequest + ): Promise => { + throw new Error('Team messaging is not available in browser mode'); + }, + createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => { + throw new Error('Team task creation is not available in browser mode'); + }, + requestReview: async (_teamName: string, _taskId: string): Promise => { + throw new Error('Team review is not available in browser mode'); + }, + updateKanban: async ( + _teamName: string, + _taskId: string, + _patch: UpdateKanbanPatch + ): Promise => { + throw new Error('Team kanban is not available in browser mode'); + }, + updateTaskStatus: async ( + _teamName: string, + _taskId: string, + _status: TeamTaskStatus + ): Promise => { + throw new Error('Team task status update 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'); + }, + processAlive: async (_teamName: string): Promise => { + return false; + }, + aliveList: async (): Promise => { + return []; + }, + onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { + return this.addEventListener('team-change', (data: unknown) => + callback(null, data as TeamChangeEvent) + ); + }, + onProvisioningProgress: ( + _callback: (event: unknown, data: TeamProvisioningProgress) => void + ): (() => void) => { + return () => {}; + }, }; } diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index ebe286cf..905afd71 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -8,6 +8,7 @@ import { CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; @@ -31,57 +32,6 @@ interface TeammateMessageItemProps { highlightStyle?: React.CSSProperties; } -/** Operational noise types that should be rendered minimally */ -const NOISE_TYPES = new Set([ - 'idle_notification', - 'shutdown_approved', - 'teammate_terminated', - 'shutdown_request', -]); - -/** Human-readable labels for noise message types */ -const NOISE_LABELS: Record = { - idle_notification: 'Idle', - shutdown_approved: 'Shutdown confirmed', - teammate_terminated: 'Terminated', - shutdown_request: 'Shutdown requested', -}; - -/** - * Detect operational noise in teammate message content. - * Returns label if noise, null if real content. - */ -function detectNoise(content: string, teammateId: string): string | null { - // System messages are always noise - if (teammateId === 'system') { - const trimmed = content.trim(); - if (trimmed.startsWith('{')) { - try { - const parsed = JSON.parse(trimmed) as { type?: string; message?: string }; - if (parsed.type && NOISE_TYPES.has(parsed.type)) { - return parsed.message ?? NOISE_LABELS[parsed.type] ?? parsed.type; - } - } catch { - // Not JSON, fall through - } - } - return trimmed.length < 200 ? trimmed : null; - } - - // Non-system: check if content is a JSON operational message - const trimmed = content.trim(); - if (!trimmed.startsWith('{')) return null; - try { - const parsed = JSON.parse(trimmed) as { type?: string }; - if (parsed.type && NOISE_TYPES.has(parsed.type)) { - return NOISE_LABELS[parsed.type] ?? parsed.type; - } - } catch { - // Not JSON - } - return null; -} - // ============================================================================= // Resend Detection // ============================================================================= @@ -128,7 +78,7 @@ export const TeammateMessageItem: React.FC = ({ // Detect operational noise const noiseLabel = useMemo( - () => detectNoise(teammateMessage.content, teammateMessage.teammateId), + () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), [teammateMessage.content, teammateMessage.teammateId] ); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index a77e6e9d..d2b086ab 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -8,6 +8,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; import { NotificationsView } from '../notifications/NotificationsView'; import { SettingsView } from '../settings/SettingsView'; +import { TeamDetailView } from '../team/TeamDetailView'; import { TeamListView } from '../team/TeamListView'; import { SessionTabContent } from './SessionTabContent'; @@ -44,6 +45,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'notifications' && } {tab.type === 'settings' && } {tab.type === 'teams' && } + {tab.type === 'team' && } {tab.type === 'session' && ( diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 7f1de844..f6798c7e 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -31,6 +31,7 @@ const TAB_ICONS = { settings: Settings, session: FileText, teams: Users, + team: Users, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx new file mode 100644 index 00000000..1bafa55b --- /dev/null +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { ChevronRight } from 'lucide-react'; + +interface CollapsibleTeamSectionProps { + title: string; + badge?: string | number; + defaultOpen?: boolean; + action?: React.ReactNode; + children: React.ReactNode; +} + +export const CollapsibleTeamSection = ({ + title, + badge, + defaultOpen = true, + action, + children, +}: CollapsibleTeamSectionProps): React.JSX.Element => { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+
+ + {action &&
{action}
} +
+ {open &&
{children}
} +
+ ); +}; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx new file mode 100644 index 00000000..f09971e9 --- /dev/null +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { Plus, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { ActivityTimeline } from './activity/ActivityTimeline'; +import { MessageComposer } from './activity/MessageComposer'; +import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; +import { ReviewDialog } from './dialogs/ReviewDialog'; +import { KanbanBoard } from './kanban/KanbanBoard'; +import { MemberList } from './members/MemberList'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; + +interface TeamDetailViewProps { + teamName: string; +} + +interface CreateTaskDialogState { + open: boolean; + defaultSubject: string; + defaultDescription: string; +} + +export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [createTaskDialog, setCreateTaskDialog] = useState({ + open: false, + defaultSubject: '', + defaultDescription: '', + }); + const [creatingTask, setCreatingTask] = useState(false); + + const { + data, + loading, + error, + selectTeam, + updateKanban, + updateTaskStatus, + sendTeamMessage, + requestReview, + createTeamTask, + deleteTeam, + openTeamsTab, + sendingMessage, + sendMessageError, + lastSendMessageResult, + reviewActionError, + } = useStore( + useShallow((s) => ({ + data: s.selectedTeamData, + loading: s.selectedTeamLoading, + error: s.selectedTeamError, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateTaskStatus: s.updateTaskStatus, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + })) + ); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + }, [teamName, selectTeam]); + + const openCreateTaskDialog = (subject = '', description = ''): void => { + setCreateTaskDialog({ open: true, defaultSubject: subject, defaultDescription: description }); + }; + + const closeCreateTaskDialog = (): void => { + setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '' }); + }; + + const handleDeleteTeam = useCallback((): void => { + const confirmed = window.confirm( + `Удалить команду "${teamName}"? Это действие необратимо. Будут удалены все данные команды и задачи.` + ); + if (!confirmed) { + return; + } + void (async () => { + try { + await deleteTeam(teamName); + openTeamsTab(); + } catch { + // error is shown via store + } + })(); + }, [teamName, deleteTeam, openTeamsTab]); + + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + }); + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + if (!teamName) { + return ( +
+ Invalid team tab +
+ ); + } + + if (loading && !data) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Не удалось загрузить команду

+

{error}

+
+
+ ); + } + + if (!data) { + return ( +
+ Нет данных по команде +
+ ); + } + + return ( +
+
+

{data.config.name}

+ {data.config.description && ( +

{data.config.description}

+ )} +
+ + + + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Не удалось полностью загрузить kanban. Отображены безопасные данные. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + + + + + { + e.stopPropagation(); + openCreateTaskDialog(); + }} + > + + Задача + + } + > + { + void requestReview(teamName, taskId); + }} + onApprove={(taskId) => { + void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void updateKanban(teamName, taskId, { op: 'remove' }); + }} + onCompleteTask={(taskId) => { + void updateTaskStatus(teamName, taskId, 'completed'); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.add('ring-2', 'ring-blue-400/50'); + setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); + } + }} + /> + + + +
+ { + void sendTeamMessage(teamName, { member, text, summary }); + }} + /> +
+ { + openCreateTaskDialog(subject, description); + }} + /> +
+
+
+ + setRequestChangesTaskId(null)} + onSubmit={(comment) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + + +
+ +
+
+ ); +}; diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx index 61d189e6..f3042a9e 100644 --- a/src/renderer/components/team/TeamEmptyState.tsx +++ b/src/renderer/components/team/TeamEmptyState.tsx @@ -1,6 +1,6 @@ export const TeamEmptyState = (): React.JSX.Element => { return ( -
+

Команды не найдены

diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index cb743b1c..87d2b255 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -1,21 +1,62 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; +import { Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; export const TeamListView = (): React.JSX.Element => { const electronMode = isElectronMode(); - const { teams, teamsLoading, teamsError, fetchTeams } = useStore( + const [showCreateDialog, setShowCreateDialog] = useState(false); + const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam } = useStore( useShallow((s) => ({ teams: s.teams, teamsLoading: s.teamsLoading, teamsError: s.teamsError, fetchTeams: s.fetchTeams, + openTeamTab: s.openTeamTab, + deleteTeam: s.deleteTeam, })) ); + const { + connectionMode, + createTeam, + cancelProvisioning, + provisioningRuns, + activeProvisioningRunId, + provisioningError, + } = useStore( + useShallow((s) => ({ + connectionMode: s.connectionMode, + createTeam: s.createTeam, + cancelProvisioning: s.cancelProvisioning, + provisioningRuns: s.provisioningRuns, + activeProvisioningRunId: s.activeProvisioningRunId, + provisioningError: s.provisioningError, + })) + ); + const activeProgress = useMemo( + () => (activeProvisioningRunId ? (provisioningRuns[activeProvisioningRunId] ?? null) : null), + [activeProvisioningRunId, provisioningRuns] + ); + const canCreate = electronMode && connectionMode === 'local'; + + const handleDeleteTeam = useCallback( + (teamName: string, e: React.MouseEvent) => { + e.stopPropagation(); + const confirmed = window.confirm(`Удалить команду "${teamName}"? Это действие необратимо.`); + if (!confirmed) { + return; + } + void deleteTeam(teamName); + }, + [deleteTeam] + ); useEffect(() => { if (!electronMode) { @@ -39,68 +80,150 @@ export const TeamListView = (): React.JSX.Element => { ); } + const createDialogElement = ( + t.teamName)} + onClose={() => setShowCreateDialog(false)} + onCreate={async (request) => { + await createTeam(request); + }} + onCancelProvisioning={async (runId) => { + await cancelProvisioning(runId); + }} + onOpenTeam={openTeamTab} + /> + ); + + const renderHeader = (): React.JSX.Element => ( +

+
+

Teams

+
+ + +
+
+ {!canCreate ? ( +

+ Доступно только в local Electron-режиме. +

+ ) : null} +
+ ); + if (teamsLoading) { return ( -
- Загружаем команды... +
+ {renderHeader()} +
+ Загружаем команды... +
+ {createDialogElement}
); } if (teamsError) { return ( -
-
-

Не удалось загрузить команды

-

{teamsError}

- +
+ {renderHeader()} +
+
+

Не удалось загрузить команды

+

{teamsError}

+ +
+ {createDialogElement}
); } if (teams.length === 0) { - return ; + return ( +
+ {renderHeader()} + + {createDialogElement} +
+ ); } return (
-
-

Teams

- -
+ {renderHeader()}
{teams.map((team) => ( -
openTeamTab(team.teamName)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamTab(team.teamName); + } + }} > -

{team.name}

+
+

+ {team.displayName} +

+ +

{team.description || 'Без описания'}

-
- Участников: {team.memberCount} - Задач: {team.taskCount} +
+ + Участников: {team.memberCount} + + + Задач: {team.taskCount} +
-
+
))}
+ {createDialogElement}
); }; diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx new file mode 100644 index 00000000..b2ca0bea --- /dev/null +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -0,0 +1,172 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; +import { X } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; + +import type { ProvisioningStep } from './provisioningSteps'; +import type { TeamProvisioningProgress } from '@shared/types'; + +interface TeamProvisioningBannerProps { + teamName: string; +} + +function findProgressForTeam( + runs: Record, + teamName: string +): TeamProvisioningProgress | null { + const entries = Object.values(runs); + const matching = entries + .filter((r) => r.teamName === teamName) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return matching[0] ?? null; +} + +const READY_DISMISS_MS = 5_000; + +export const TeamProvisioningBanner = ({ + teamName, +}: TeamProvisioningBannerProps): React.JSX.Element | null => { + const { provisioningRuns, cancelProvisioning } = useStore( + useShallow((s) => ({ + provisioningRuns: s.provisioningRuns, + cancelProvisioning: s.cancelProvisioning, + })) + ); + + const progress = findProgressForTeam(provisioningRuns, teamName); + const [dismissed, setDismissed] = useState(false); + const prevRunIdRef = useRef(progress?.runId); + + if (prevRunIdRef.current !== progress?.runId) { + prevRunIdRef.current = progress?.runId; + if (dismissed) { + setDismissed(false); + } + } + + useEffect(() => { + if (progress?.state !== 'ready') { + return; + } + const timer = window.setTimeout(() => { + setDismissed(true); + }, READY_DISMISS_MS); + return () => { + window.clearTimeout(timer); + }; + }, [progress?.state, progress?.runId]); + + if (!progress || dismissed) { + return null; + } + + if (progress.state === 'ready' || progress.state === 'cancelled') { + return null; + } + + const isFailed = progress.state === 'failed'; + const isDisconnected = progress.state === 'disconnected'; + const isActive = + progress.state === 'validating' || + progress.state === 'spawning' || + progress.state === 'monitoring' || + progress.state === 'verifying'; + + const canCancel = + progress.state === 'spawning' || + progress.state === 'monitoring' || + progress.state === 'verifying'; + + const progressStepIndex = STEP_ORDER.indexOf(progress.state as ProvisioningStep); + + if (isFailed) { + return ( +
+

{progress.message}

+ +
+ ); + } + + if (isDisconnected) { + return ( +
+

Team offline

+ +
+ ); + } + + if (isActive) { + return ( +
+
+

{progress.message}

+ {canCancel ? ( + + ) : null} +
+
+ {STEP_ORDER.map((step, index) => { + const isDone = progressStepIndex >= 0 && index < progressStepIndex; + const isCurrent = progressStepIndex >= 0 && index === progressStepIndex; + + return ( +
+ + + {index + 1} + + {STEP_LABELS[step]} + + {index < STEP_ORDER.length - 1 ? ( + + ) : null} +
+ ); + })} +
+
+ ); + } + + return null; +}; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx new file mode 100644 index 00000000..8d43f5e2 --- /dev/null +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -0,0 +1,263 @@ +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { cn } from '@renderer/lib/utils'; +import { + getMessageTypeLabel, + getStructuredMessageSummary, + parseStructuredAgentMessage, +} from '@renderer/utils/agentMessageFormatting'; +import { CheckCircle2, Circle, ListPlus, LogOut, Power, PowerOff } from 'lucide-react'; + +import type { InboxMessage } from '@shared/types'; + +type StructuredMessage = Record; + +interface ActivityItemProps { + message: InboxMessage; + onCreateTask?: (subject: string, description: string) => void; +} + +function getStringField(obj: StructuredMessage, key: string): string | null { + const value = obj[key]; + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +function formatIdleReason(reason: string | null): string { + if (!reason) { + return 'idle'; + } + return reason; +} + +function isCompactStructuredDisplay(parsed: StructuredMessage): boolean { + const type = getStringField(parsed, 'type'); + return ( + type === 'idle_notification' || + type === 'shutdown_response' || + type === 'shutdown_request' || + type === 'shutdown_approved' || + type === 'teammate_terminated' || + type === 'task_completed' + ); +} + +function agentAvatarUrl(name: string): string { + return `https://robohash.org/${encodeURIComponent(name)}?size=48x48`; +} + +const AgentAvatar = ({ name }: { name: string }): React.JSX.Element => ( + {name} +); + +const CompactStatusLine = ({ + icon, + text, + className, +}: { + icon: React.ReactNode; + text: string; + className?: string; +}): React.JSX.Element => ( +
+ {icon} + {text} +
+); + +const StructuredCompactDisplay = ({ + parsed, +}: { + parsed: StructuredMessage; +}): React.JSX.Element | null => { + const type = getStringField(parsed, 'type'); + const from = getStringField(parsed, 'from'); + const agentName = from ?? 'agent'; + + if (type === 'idle_notification') { + const idleReason = getStringField(parsed, 'idleReason'); + return ( + } + text={`${agentName} is idle (${formatIdleReason(idleReason)})`} + className="bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]" + /> + ); + } + + if (type === 'shutdown_response') { + const approved = parsed.approve === true; + if (approved) { + return ( + } + text={`${agentName} has shut down`} + className="bg-red-500/5 text-red-300" + /> + ); + } + const reason = getStringField(parsed, 'content') ?? 'declined'; + return ( + } + text={`${agentName} rejected shutdown: ${reason}`} + className="bg-amber-500/5 text-amber-300" + /> + ); + } + + if (type === 'shutdown_request') { + const recipient = getStringField(parsed, 'recipient') ?? agentName; + return ( + } + text={`Shutdown requested for ${recipient}`} + className="bg-amber-500/5 text-amber-300" + /> + ); + } + + if (type === 'shutdown_approved' || type === 'teammate_terminated') { + return ( + } + text={`${agentName} ${type === 'shutdown_approved' ? 'shutdown confirmed' : 'terminated'}`} + className="bg-red-500/5 text-red-300" + /> + ); + } + + if (type === 'task_completed') { + const rawTaskId = parsed.taskId; + const taskId = + typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null; + const taskLabel = taskId !== null ? `task #${taskId}` : 'a task'; + return ( + } + text={`${agentName} completed ${taskLabel}`} + className="bg-emerald-500/5 text-emerald-300" + /> + ); + } + + return null; +}; + +const StructuredFallbackDisplay = ({ + parsed, + autoSummary, +}: { + parsed: StructuredMessage; + autoSummary: string; +}): React.JSX.Element => ( +
+

{autoSummary}

+
+ + Raw JSON + +
+        {JSON.stringify(parsed, null, 2)}
+      
+
+
+); + +export const ActivityItem = ({ message, onCreateTask }: ActivityItemProps): React.JSX.Element => { + const timestamp = Number.isNaN(Date.parse(message.timestamp)) + ? message.timestamp + : new Date(message.timestamp).toLocaleString(); + const structured = parseStructuredAgentMessage(message.text); + const messageType = + structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null; + const autoSummary = structured ? getStructuredMessageSummary(structured) : null; + + const handleCreateTask = (): void => { + const subject = message.summary || autoSummary || `Task from ${message.from}`; + const plainText = structured ? JSON.stringify(structured, null, 2) : message.text; + const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); + onCreateTask?.(subject, description); + }; + + const isCompact = structured !== null && isCompactStructuredDisplay(structured); + + if (isCompact && structured) { + return ( +
+
+
+ + +
+
+ {onCreateTask && ( + + )} +

{timestamp}

+
+
+
+ ); + } + + return ( +
+
+
+ +

+ {message.from} + {message.to && message.to !== message.from ? ( + + {' → '} + {message.to} + + ) : null} +

+ {messageType ? ( + + {messageType} + + ) : null} +
+
+ {onCreateTask && ( + + )} +

{timestamp}

+
+
+ {message.summary ? ( +

{message.summary}

+ ) : autoSummary && autoSummary !== messageType ? ( +

{autoSummary}

+ ) : null} + {structured ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx new file mode 100644 index 00000000..0c9cbaef --- /dev/null +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -0,0 +1,34 @@ +import { ActivityItem } from './ActivityItem'; + +import type { InboxMessage } from '@shared/types'; + +interface ActivityTimelineProps { + messages: InboxMessage[]; + onCreateTaskFromMessage?: (subject: string, description: string) => void; +} + +export const ActivityTimeline = ({ + messages, + onCreateTaskFromMessage, +}: ActivityTimelineProps): React.JSX.Element => { + if (messages.length === 0) { + return ( +
+

Нет сообщений

+

Отправьте сообщение участнику, чтобы увидеть активность.

+
+ ); + } + + return ( +
+ {messages.slice(0, 200).map((message) => ( + + ))} +
+ ); +}; diff --git a/src/renderer/components/team/activity/MessageComposer.tsx b/src/renderer/components/team/activity/MessageComposer.tsx new file mode 100644 index 00000000..e3f48040 --- /dev/null +++ b/src/renderer/components/team/activity/MessageComposer.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { Textarea } from '@renderer/components/ui/textarea'; +import { ChevronRight, MessageSquare } from 'lucide-react'; + +import type { ResolvedTeamMember, SendMessageResult } from '@shared/types'; + +interface MessageComposerProps { + members: ResolvedTeamMember[]; + sending: boolean; + sendError: string | null; + lastResult: SendMessageResult | null; + onSend: (member: string, text: string, summary?: string) => void; +} + +const NO_MEMBER = '__none__'; + +export const MessageComposer = ({ + members, + sending, + sendError, + lastResult, + onSend, +}: MessageComposerProps): React.JSX.Element => { + const [open, setOpen] = useState(false); + const [member, setMember] = useState(''); + const [text, setText] = useState(''); + const [summary, setSummary] = useState(''); + + const canSend = member.trim().length > 0 && text.trim().length > 0 && !sending; + + return ( +
+ + + {open ? ( +
+
+
+ + +
+ +
+ + setSummary(event.target.value)} + /> +
+ +
+ +