Teammates are independent Claude Code processes that read their own inbox files via fs.watch. Relaying DMs through the lead caused three bugs: lead responding instead of the teammate, duplicate messages from relay loops, and teammates not responding to user due to conflicting prompts. - Disable relayMemberInboxMessages for teammate DMs (teams.ts, index.ts) - Add SendMessage(to="user") filter in captureSendMessages as safety net - Generate deterministic messageId for inbox entries lacking one (sha256) - Wrap notification instructions in agent block, italic task subject - Style system comments in task view with blue background, hide avatar - Update CLAUDE.md, research docs, and code comments with architecture
228 lines
8.8 KiB
Markdown
228 lines
8.8 KiB
Markdown
# Research: Inbox-файлы Claude Code
|
||
|
||
## Формат
|
||
|
||
**Путь**: `~/.claude/teams/{teamName}/inboxes/{memberName}.json`
|
||
|
||
**Структура**: JSON-массив объектов (весь файл = массив)
|
||
|
||
```json
|
||
[
|
||
{
|
||
"from": "contracts-cleaner",
|
||
"text": "Готово. Вот что было удалено из packages/core/...",
|
||
"timestamp": "2026-02-09T17:12:32.316Z",
|
||
"color": "blue",
|
||
"read": true,
|
||
"summary": "Cleanup complete"
|
||
},
|
||
{
|
||
"from": "team-lead",
|
||
"text": "{\"type\":\"shutdown_request\",\"from\":\"team-lead\",\"timestamp\":\"...\"}",
|
||
"timestamp": "2026-02-09T17:25:43.886Z",
|
||
"read": true
|
||
}
|
||
]
|
||
```
|
||
|
||
### Поля
|
||
|
||
| Поле | Обязательно | Тип | Описание |
|
||
|------|:-----------:|-----|----------|
|
||
| `from` | YES | string | Имя отправителя (зарегистрированный agent) |
|
||
| `text` | YES | string | Текст сообщения или JSON-строка |
|
||
| `timestamp` | YES | string | ISO 8601 |
|
||
| `read` | YES | boolean | Прочитано ли Claude Code |
|
||
| `summary` | NO | string | Краткое описание для UI |
|
||
| `color` | NO | string | Цвет агента (blue, green, red, yellow, purple, cyan, orange, pink) |
|
||
|
||
### text: plain vs structured
|
||
|
||
`text` может быть:
|
||
- **Plain text**: обычное сообщение
|
||
- **JSON-строка**: структурированное сообщение (парсится из text)
|
||
|
||
Типы структурированных:
|
||
```
|
||
idle_notification — агент ушёл в idle (idleReason: "available")
|
||
shutdown_request — запрос на завершение работы
|
||
shutdown_approved — подтверждение завершения
|
||
message — обычное DM (content, summary)
|
||
task_completed — задача завершена (taskId)
|
||
```
|
||
|
||
### read поведение
|
||
|
||
- Claude Code ставит `read: true` после обработки
|
||
- Сообщения НЕ УДАЛЯЮТСЯ — остаются навечно
|
||
- Нет автоматической чистки inbox
|
||
- 106 сообщений = ~256 KB
|
||
|
||
---
|
||
|
||
## Race Condition (КРИТИЧНЫЙ РИСК)
|
||
|
||
### Сценарий
|
||
|
||
```
|
||
T=0ms: App читает inbox [msg1, msg2]
|
||
T=1ms: Claude Code читает inbox [msg1, msg2]
|
||
T=5ms: App пишет [msg1, msg2, msg3_app]
|
||
T=6ms: Claude Code пишет [msg1, msg2, msg3_agent]
|
||
→ msg3_app ПОТЕРЯНО
|
||
```
|
||
|
||
### Почему происходит
|
||
|
||
Inbox — JSON-массив. Append = read whole array → add element → write whole file. Два процесса читают одну версию, каждый добавляет своё, последний перезаписывает первого.
|
||
|
||
### Вероятность
|
||
|
||
**НИЗКАЯ** — записи в inbox происходят нечасто:
|
||
- Юзер шлёт 1-2 сообщения в минуту
|
||
- Агенты шлют idle_notification раз в 5-30 секунд
|
||
- Коллизия = оба пишут в ОДИН файл в пределах ~10ms
|
||
|
||
### Митигация
|
||
|
||
1. **Atomic write** (предотвращает partial writes):
|
||
```typescript
|
||
const tmpPath = targetPath + '.tmp.' + process.pid;
|
||
fs.writeFileSync(tmpPath, JSON.stringify(messages, null, 2));
|
||
fs.renameSync(tmpPath, targetPath); // atomic на macOS/Linux
|
||
```
|
||
|
||
2. **Retry с проверкой** (обнаруживает потерю):
|
||
```typescript
|
||
// После записи — перечитать и проверить
|
||
const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||
const found = written.some(m => m.messageId === ourMessageId);
|
||
if (!found) {
|
||
// Наше сообщение потеряно — retry
|
||
await appendToInbox(inboxPath, message);
|
||
}
|
||
```
|
||
|
||
3. **Уникальный messageId**: добавлять `messageId: uuid()` в наши сообщения
|
||
|
||
4. **Debounce**: не писать чаще раз в 500ms
|
||
|
||
### Что НЕ решает
|
||
|
||
Atomic write предотвращает corrupted JSON, но НЕ предотвращает overwrite race. Retry с проверкой — best effort, но не 100%.
|
||
|
||
---
|
||
|
||
## Доставка сообщений
|
||
|
||
### Между turns, НЕ real-time
|
||
|
||
```
|
||
Цикл тиммейта:
|
||
1. Читает inbox (видит новые сообщения с read: false)
|
||
2. Обрабатывает
|
||
3. Вызывает инструменты (Bash, Edit, Read...)
|
||
4. Turn заканчивается → шлёт idle_notification → IDLE
|
||
5. Ждёт...
|
||
6. Новый turn → читает inbox
|
||
```
|
||
|
||
**Задержка**: 1-30 секунд (зависит от длительности turn)
|
||
|
||
### Нельзя прервать mid-turn
|
||
|
||
Если агент уже вызвал Edit/Bash — инструмент будет выполнен. Сообщение "стоп, не трогай файл X" придёт ПОСЛЕ.
|
||
|
||
### Idle → Active
|
||
|
||
Сообщение idle-агенту пробуждает его при следующем цикле проверки inbox.
|
||
|
||
### Hard Interrupt (будущее)
|
||
|
||
Возможные подходы:
|
||
- `kill -SIGINT` процесса (жёсткое)
|
||
- Файловый flag `.interrupt-{member}`
|
||
- Ждать API от Anthropic
|
||
|
||
---
|
||
|
||
## from: "user" — подтверждено работает (2026-03-23)
|
||
|
||
Эмпирически подтверждено: `from: "user"` корректно доставляется тиммейтам. Тиммейт получает сообщение, определяет что оно от юзера, и отвечает в `inboxes/user.json`. Fallback на `from: "team-lead"` не нужен.
|
||
|
||
Ранее были опасения что Claude Code валидирует `from` по `config.json` members — это не так.
|
||
|
||
---
|
||
|
||
## Размер и масштабирование
|
||
|
||
| Метрика | Значение |
|
||
|---------|----------|
|
||
| Размер сообщения | ~2.4 KB |
|
||
| 100 сообщений | ~240 KB |
|
||
| 1000 сообщений | ~2.4 MB |
|
||
| Парсинг 1000 сообщений | <10ms |
|
||
| Реальный inbox (106 msgs) | 256 KB |
|
||
|
||
Проблема начнётся при 10000+ сообщений — JSON.parse будет заметно медленнее. Для долгоживущих команд нужна архивация.
|
||
|
||
---
|
||
|
||
## Финальное решение (после 3 раундов ревью)
|
||
|
||
### Подход: Atomic write + messageId verify
|
||
|
||
Выбрана комбинация atomic write с постфактум-верификацией через `messageId`:
|
||
|
||
```typescript
|
||
// 1. Генерируем уникальный ID для нашего сообщения
|
||
const messageId = crypto.randomUUID();
|
||
const message: InboxMessage = {
|
||
from: 'user',
|
||
text,
|
||
timestamp: new Date().toISOString(),
|
||
read: false,
|
||
summary,
|
||
messageId,
|
||
};
|
||
|
||
// 2. Читаем текущий inbox
|
||
const existing = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||
|
||
// 3. Добавляем сообщение
|
||
const updated = [...existing, message];
|
||
|
||
// 4. Atomic write (tmp + rename)
|
||
const tmpPath = inboxPath + '.tmp.' + process.pid;
|
||
fs.writeFileSync(tmpPath, JSON.stringify(updated, null, 2));
|
||
fs.renameSync(tmpPath, inboxPath);
|
||
|
||
// 5. Verify: перечитываем и проверяем messageId
|
||
const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||
const found = written.some((m: InboxMessage) => m.messageId === messageId);
|
||
if (!found) {
|
||
// Потеря обнаружена — показать warning в UI, не silent fail
|
||
throw new Error(`Message ${messageId} lost during write`);
|
||
}
|
||
```
|
||
|
||
### Решения по итогам ревью
|
||
|
||
- **Полный CAS не нужен на MVP**: verify при следующем read достаточен для обнаружения потерь
|
||
- **messageId проверяется сразу после записи** (а не только при следующем read)
|
||
- **Не silent fail**: если сообщение потеряно — UI показывает предупреждение пользователю
|
||
- **Retry не автоматический**: потеря крайне редка, ручная отправка достаточна на MVP
|
||
|
||
### Риски
|
||
|
||
| Риск | Вероятность | Митигация |
|
||
|------|-------------|-----------|
|
||
| Race condition: агент пишет одновременно | Низкая | Atomic write + verify |
|
||
| Потеря при race | Очень низкая | messageId verify → warning в UI |
|
||
| Corrupted JSON | Практически 0 | Atomic write (tmp + rename) |
|
||
|
||
### Что не входит в MVP
|
||
|
||
- Автоматический retry при потере (добавить в Phase 2 при необходимости)
|
||
- Debounce записи (не нужен при редкой записи)
|
||
- Полный CAS с блокировкой (избыточно для данной частоты записей)
|