20 KiB
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
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)
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:
{"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
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)
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
async function resolveClaudeBinary(): Promise<string | null> {
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/launchctlfix-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
- Buffer overflow: stdout buffer = 64KB. Claude team session легко генерит > 64KB → subprocess deadlock
- Partial JSON reads: chunk boundary может порвать JSON object → нужен stateful line parser
- Backpressure: если не drain'ить pipe → Claude CLI зависает навсегда
- Encoding: multi-byte UTF-8 рвётся на chunk boundaries
- 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 выключены по умолчанию. Для включения:
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
Или в ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
Доступные team tools:
TeamCreate— создание командыTaskCreate— создание задачиTaskUpdate— обновление статуса задачиTaskList— список задачTaskGet— получение задачи по IDSendMessage— отправка сообщения участникуTeamDelete— удаление команды
6. Ограничения Agent Teams (из официальной документации)
/resumeи/rewindломают team state- Task status может отставать — teammates иногда не отмечают tasks completed
- Shutdown медленный — teammates завершают текущий request перед выходом
- Один team per session — нельзя управлять несколькими командами из одного lead
- Нет nested teams — teammates не могут создавать свои команды
- Token cost высокий — каждый teammate = отдельное context window
- Split-pane mode fragile — требует tmux или iTerm2
7. Верификация (раунд 3, 3 агента)
7.1 Вердикт по SDK: НЕТ
Финальный вердикт: SDK query() НЕ использовать.
Причины:
- SDK внутри спавнит CLI как subprocess — нет выигрыша по сравнению с прямым spawn
- ~12 сек cold start per query() call — неприемлемо для UX
- Pre-1.0: API может измениться без предупреждения
- SDK не решает проблему PATH resolution — всё равно нужен resolver
- Team features experimental — задокументированные баги с session resumption, task lag, slow shutdown
- Никакой добавленной ценности: 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 binaryspawning— spawn CLI processmonitoring— ожидание config.json через FileWatcherready— команда создана, participants bootstrappedfailed— ошибка (с retry)
IPC Contract
// 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
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
// 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
validateTeamName()проверяет формат (a-z, 0-9, -)- Если dir уже существует с config.json → ошибка (не перезаписываем чужую команду)
- Если dir существует БЕЗ config.json → очищаем и продолжаем (неудачная предыдущая попытка)
- 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
- Run Claude Code programmatically
- Orchestrate teams
- Agent SDK overview
- Agent SDK TypeScript
NPM / GitHub
Known Issues
- #5034 — Duplicate entries in stream-json
- #1920 — Missing Final Result Event in Streaming JSON
- #34 — Hot process reuse for SDK
- #15511 — Stream partial JSON tokens