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
8.8 KiB
Research: Inbox-файлы Claude Code
Формат
Путь: ~/.claude/teams/{teamName}/inboxes/{memberName}.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
Митигация
- Atomic write (предотвращает partial writes):
const tmpPath = targetPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(messages, null, 2));
fs.renameSync(tmpPath, targetPath); // atomic на macOS/Linux
- Retry с проверкой (обнаруживает потерю):
// После записи — перечитать и проверить
const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
const found = written.some(m => m.messageId === ourMessageId);
if (!found) {
// Наше сообщение потеряно — retry
await appendToInbox(inboxPath, message);
}
-
Уникальный messageId: добавлять
messageId: uuid()в наши сообщения -
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:
// 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 с блокировкой (избыточно для данной частоты записей)