agent-ecosystem/docs/research/minimal-adapter-design.md
iliya fe90ac866d Add deep dive research documentation on AI agent orchestration and communication standards
- Introduced multiple markdown files covering agent spawn packages, inter-agent communication protocols, and multi-agent orchestration tools.
- Detailed analysis of official SDKs for CLI agents (Claude Code, Codex, Gemini) and their integration potential.
- Documented various competitor approaches to agent spawning and communication, highlighting strengths and weaknesses.
- Provided insights into best practices for implementing multi-provider support within Electron applications.

This comprehensive documentation aims to enhance understanding of the current AI agent ecosystem and serve as a resource for developers and stakeholders.
2026-03-27 17:51:49 +02:00

689 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Minimal CLI Agent Adapter Design
**Дата**: 2026-03-25
**Статус**: Research / Design proposal
## Цель
Определить МИНИМАЛЬНО достаточный адаптер для запуска нескольких CLI-агентов (Claude, Codex, Gemini, Goose, OpenCode) из нашего Electron-приложения. Без over-engineering, без "велосипедов".
---
## 1. Что мы уже имеем
### childProcess.ts (221 LOC)
Уже содержит два ключевых примитива:
- **`spawnCli(binaryPath, args, options)`** — spawn с Windows EINVAL fallback
- **`execCli(binaryPath, args, options)`** — exec для одноразовых команд
- **`killProcessTree(child, signal)`** — kill с Windows taskkill fallback
- **`CLI_ENV_DEFAULTS`** — env-переменные для Claude (CLAUDE_HOOK_JUDGE_MODE)
### TeamProvisioningService.ts (~8000+ LOC)
Монстр, который делает ВСЁ:
- Spawn через `spawnCli()`
- Конструирование args (`--input-format stream-json`, `--output-format stream-json`, `--mcp-config`, `--verbose`, etc.)
- Парсинг stream-json stdout (newline-delimited JSON)
- Stdin messaging (SDKUserMessage format)
- MCP config merge (через TeamMcpConfigBuilder)
- Filesystem monitoring, stall detection, auth retry, etc.
### ScheduledTaskExecutor.ts (~200 LOC)
Отдельный, более чистый spawn-path для scheduled tasks:
- Тоже `spawnCli()` + `--output-format stream-json`
- Парсинг stdout для summary extraction
- Простой lifecycle: spawn -> wait -> collect result
### TeamMcpConfigBuilder.ts (229 LOC)
Генерирует MCP config JSON-файл, мержит с user-серверами из `~/.claude.json`.
### Общий паттерн spawn (из TeamProvisioningService):
```typescript
const spawnArgs = [
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--verbose',
'--setting-sources', 'user,project,local',
'--mcp-config', mcpConfigPath,
'--disallowedTools', 'TeamDelete,TodoWrite',
...(skipPermissions ? ['--dangerously-skip-permissions'] : []),
...(model ? ['--model', model] : []),
];
child = spawnCli(claudePath, spawnArgs, {
cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
});
// stdin: send JSON messages
child.stdin.write(JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text: prompt }] }
}) + '\n');
```
---
## 2. Что РЕАЛЬНО отличается между CLI-агентами
### Сводная таблица (исследование март 2026)
| Аспект | Claude Code | Codex (OpenAI) | Gemini CLI | Goose (Block) | OpenCode |
|--------|-------------|-----------------|------------|---------------|----------|
| **Binary** | `claude` | `codex` | `gemini` | `goose` | `opencode` |
| **Programmatic mode** | `--input-format stream-json --output-format stream-json` | `codex exec --json` (NDJSON events) | `--output-format json` (headless) | `goose run --output-format stream-json` | `opencode run --format json` |
| **Stdin messaging** | stream-json protocol (SDKUserMessage) | Нет stdin — одноразовый exec | Нет stdin — одноразовый | Нет stdin — одноразовый `run` | Нет stdin — pipe prompt или `--attach` |
| **Output protocol** | NDJSON (type: user/assistant/result/control_request/system) | NDJSON events | JSON (структура неизвестна) | NDJSON (text/json/stream-json) | JSON events |
| **MCP config** | `--mcp-config /path/to/file.json` | `config.toml` (`codex mcp add`) | `settings.json` (`gemini mcp add`) | `--with-extension "cmd"` (runtime) | Config file (opencode.json) |
| **MCP config format** | `{ mcpServers: { name: { command, args } } }` | TOML (встроенная команда `codex mcp`) | JSON settings.json `{ mcpServers: {...} }` | CLI flags per extension | JSON config |
| **Kill semantics** | SIGKILL (team) / SIGTERM (scheduled) | SIGTERM | SIGTERM | SIGTERM | SIGTERM |
| **Keep-alive** | Да (stream-json stdin/stdout loop) | Нет (exec = one-shot) | Нет (headless = one-shot) | Нет (run = one-shot) | Возможно (`--attach` к serve) |
| **Team/multi-agent** | Нативные Agent Teams (TeamCreate, SendMessage) | Нет встроенного | Нет встроенного | Нет встроенного | Subagents через Task tool |
| **Prompt flag** | Stdin (stream-json) или `-p` (one-shot) | `codex exec "prompt"` (positional) | `-p "prompt"` или pipe | `goose run -t "prompt"` или `-i file` | `opencode run "prompt"` (positional) |
### Источники
- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) — `codex exec --json`, NDJSON events
- [Codex MCP Docs](https://developers.openai.com/codex/mcp) — config.toml based MCP
- [Gemini CLI MCP Docs](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) — settings.json, `gemini mcp add`
- [Goose CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) — `--output-format stream-json`, `--with-extension`
- [Goose --output-format issue #4419](https://github.com/block/goose/issues/4419) — json/stream-json Done
- [OpenCode CLI Docs](https://opencode.ai/docs/cli/) — `run --format json`
- [OpenCode Agents Docs](https://opencode.ai/docs/agents/) — subagents, Task tool
---
## 3. Ключевой вывод: ГДЕ реальная сложность
### Что тривиально (просто конфиг):
- **Binary name** — строка
- **Prompt flag** — `-p`, `-t`, позиционный arg, или stdin
- **Output format flag** — `--output-format stream-json`, `--json`, `--format json`
- **Model flag** — `--model`, `-m`, `--provider/--model`
- **Permission flags** — `--dangerously-skip-permissions`, `--full-auto`, `--yolo`
- **Kill signal** — SIGKILL vs SIGTERM
### Что НЕ тривиально (требует адаптера):
1. **Stdin protocol** — ТОЛЬКО Claude имеет persistent stdin loop (stream-json). Все остальные — one-shot (запустил, получил результат, процесс завершился). Это ФУНДАМЕНТАЛЬНОЕ отличие.
2. **Output parsing** — NDJSON формат похож, но структура объектов разная. Claude: `{type: "assistant", message: {...}}`. Codex: свой формат events. Goose: свой. Gemini: свой.
3. **MCP config injection** — Claude: `--mcp-config file.json`. Codex: нужно `codex mcp add` заранее или config.toml. Gemini: нужно `gemini mcp add` или settings.json. Goose: `--with-extension` per runtime.
### Честная оценка: что из 8000 LOC TeamProvisioningService нужно для других CLI?
**НЕ нужно** (Claude-specific, 80% кода):
- stream-json stdin messaging loop
- `control_request` protocol (tool approval)
- Teammate spawn tracking (`memberSpawnStatuses`)
- Agent Teams protocol (TeamCreate, SendMessage, TaskCreate)
- Post-compact context recovery
- Cross-team messaging relay
- Lead activity state machine
- Filesystem monitoring для team files (config.json, inboxes/, tasks/)
- Auth retry через respawn
**Нужно** (общий ~20% skeleton):
- Binary resolution (`ClaudeBinaryResolver` -> обобщённый)
- Shell env resolution (`resolveInteractiveShellEnv`)
- MCP config generation и injection
- Process spawn + stdio pipes
- stdout/stderr collection
- Kill + cleanup
- Timeout/stall detection
- Progress reporting
---
## 4. Три варианта дизайна
### Option A: Config-driven (одна функция + конфиг)
**~120 LOC total** (config object + spawnAgent function + output normalizer)
```typescript
// src/main/utils/agentConfig.ts (~60 LOC)
export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode';
export type OutputProtocol = 'stream-json' | 'ndjson-events' | 'json-batch';
/** How to inject the user prompt into the CLI */
export type PromptMode =
| { type: 'stdin-stream-json' } // Claude: persistent stdin loop
| { type: 'flag'; flag: string } // -p "prompt", -t "prompt"
| { type: 'positional' } // codex exec "prompt"
| { type: 'stdin-pipe' }; // echo "prompt" | opencode run
export interface AgentConfig {
/** Binary name (resolved via PATH or explicit path) */
bin: string;
/** How to pass the prompt */
promptMode: PromptMode;
/** CLI flags for programmatic output */
outputArgs: string[];
/** How stdout should be parsed */
outputProtocol: OutputProtocol;
/** How to inject MCP servers */
mcpInjection:
| { type: 'flag'; flag: string; format: 'claude-json' } // --mcp-config file.json
| { type: 'runtime-flag'; flag: string } // --with-extension "cmd"
| { type: 'config-file'; path: string; format: 'toml' | 'json' } // write to config
| { type: 'cli-command'; command: string[] }; // codex mcp add ...
/** Signal to use for killing */
killSignal: NodeJS.Signals;
/** Extra env vars */
env?: Record<string, string>;
/** Whether the process stays alive for multi-turn (only Claude) */
persistent: boolean;
}
export const AGENT_CONFIGS: Record<AgentType, AgentConfig> = {
claude: {
bin: 'claude',
promptMode: { type: 'stdin-stream-json' },
outputArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'],
outputProtocol: 'stream-json',
mcpInjection: { type: 'flag', flag: '--mcp-config', format: 'claude-json' },
killSignal: 'SIGKILL',
env: { CLAUDE_HOOK_JUDGE_MODE: 'true' },
persistent: true,
},
codex: {
bin: 'codex',
promptMode: { type: 'positional' },
outputArgs: ['exec', '--json'],
outputProtocol: 'ndjson-events',
mcpInjection: { type: 'config-file', path: '~/.codex/config.toml', format: 'toml' },
killSignal: 'SIGTERM',
persistent: false,
},
gemini: {
bin: 'gemini',
promptMode: { type: 'flag', flag: '-p' },
outputArgs: ['--output-format', 'json'],
outputProtocol: 'json-batch',
mcpInjection: { type: 'cli-command', command: ['gemini', 'mcp', 'add'] },
killSignal: 'SIGTERM',
persistent: false,
},
goose: {
bin: 'goose',
promptMode: { type: 'flag', flag: '-t' },
outputArgs: ['run', '--output-format', 'stream-json'],
outputProtocol: 'stream-json',
mcpInjection: { type: 'runtime-flag', flag: '--with-extension' },
killSignal: 'SIGTERM',
persistent: false,
},
opencode: {
bin: 'opencode',
promptMode: { type: 'positional' },
outputArgs: ['run', '--format', 'json'],
outputProtocol: 'json-batch',
mcpInjection: { type: 'config-file', path: '.opencode.json', format: 'json' },
killSignal: 'SIGTERM',
persistent: false,
},
};
```
```typescript
// src/main/utils/agentSpawn.ts (~60 LOC)
import { spawnCli, killProcessTree } from './childProcess';
import { AGENT_CONFIGS, type AgentType, type AgentConfig } from './agentConfig';
export interface AgentSpawnOptions {
type: AgentType;
prompt: string;
cwd: string;
env?: NodeJS.ProcessEnv;
model?: string;
mcpConfigPath?: string; // pre-built MCP config file (for Claude-style --mcp-config)
extraArgs?: string[];
}
export interface SpawnedAgent {
child: import('child_process').ChildProcess;
config: AgentConfig;
kill: () => void;
/** Send message (only works for persistent agents like Claude) */
send?: (text: string) => void;
}
export function spawnAgent(options: AgentSpawnOptions): SpawnedAgent {
const config = AGENT_CONFIGS[options.type];
const args: string[] = [...config.outputArgs];
// Inject MCP config
if (options.mcpConfigPath && config.mcpInjection.type === 'flag') {
args.push(config.mcpInjection.flag, options.mcpConfigPath);
}
// Extra args
if (options.extraArgs) {
args.push(...options.extraArgs);
}
// Inject prompt based on mode
switch (config.promptMode.type) {
case 'flag':
args.push(config.promptMode.flag, options.prompt);
break;
case 'positional':
args.push(options.prompt);
break;
case 'stdin-stream-json':
case 'stdin-pipe':
// Handled after spawn
break;
}
const child = spawnCli(config.bin, args, {
cwd: options.cwd,
env: { ...(options.env ?? process.env), ...(config.env ?? {}) },
stdio: config.persistent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
});
// Send prompt via stdin if needed
if (config.promptMode.type === 'stdin-stream-json' && child.stdin?.writable) {
const msg = JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text: options.prompt }] },
});
child.stdin.write(msg + '\n');
} else if (config.promptMode.type === 'stdin-pipe' && child.stdin) {
child.stdin.write(options.prompt);
child.stdin.end();
}
return {
child,
config,
kill: () => killProcessTree(child, config.killSignal),
send: config.persistent
? (text: string) => {
if (!child.stdin?.writable) return;
const msg = JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
});
child.stdin.write(msg + '\n');
}
: undefined,
};
}
```
**Плюсы:**
- Минимум кода (~120 LOC в двух файлах)
- Нет классов, нет наследования, нет интерфейсов
- Новый CLI = добавить запись в AGENT_CONFIGS
- Легко тестировать (pure config + one function)
- Не ломает существующий код — TeamProvisioningService может использовать или не использовать
**Минусы:**
- Output parsing НЕ покрыт (каждый CLI имеет свою структуру NDJSON)
- MCP config injection для Codex/Gemini требует отдельной логики (write to config.toml, run `gemini mcp add`)
- `persistent: true` (Claude) vs one-shot (все остальные) — фундаментально разный lifecycle
**Надёжность: 7/10** — Покрывает spawn, но не parsing.
**Уверенность: 8/10** — Config-based подход проверен в ScheduledTaskExecutor.
---
### Option B: Thin interface + implementations
**~200 LOC total** (interface + claude adapter + generic one-shot adapter)
```typescript
// src/main/adapters/AgentAdapter.ts (~30 LOC)
import type { ChildProcess } from 'child_process';
export interface AgentOutput {
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'result' | 'error' | 'raw';
content: string;
raw?: unknown;
}
export interface AgentAdapter {
readonly agentType: string;
readonly persistent: boolean;
/** Build CLI args for spawning */
buildArgs(prompt: string, options: { model?: string; mcpConfigPath?: string; extraArgs?: string[] }): string[];
/** Parse a single line/chunk of stdout into normalized output */
parseOutput(line: string): AgentOutput | null;
/** Send a follow-up message (only for persistent agents) */
sendMessage?(child: ChildProcess, text: string): void;
/** Which signal to use for kill */
killSignal: NodeJS.Signals;
}
```
```typescript
// src/main/adapters/ClaudeAdapter.ts (~60 LOC)
export class ClaudeAdapter implements AgentAdapter {
readonly agentType = 'claude';
readonly persistent = true;
readonly killSignal = 'SIGKILL' as const;
buildArgs(prompt: string, options) {
const args = [
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--verbose',
];
if (options.mcpConfigPath) args.push('--mcp-config', options.mcpConfigPath);
if (options.model) args.push('--model', options.model);
args.push(...(options.extraArgs ?? []));
return args;
// prompt sent via sendMessage(), not in args
}
parseOutput(line: string): AgentOutput | null {
try {
const obj = JSON.parse(line);
if (obj.type === 'assistant') return { type: 'text', content: /* extract */, raw: obj };
if (obj.type === 'result') return { type: 'result', content: obj.result?.text ?? '', raw: obj };
return { type: 'raw', content: line, raw: obj };
} catch { return null; }
}
sendMessage(child: ChildProcess, text: string) {
if (!child.stdin?.writable) return;
child.stdin.write(JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
}) + '\n');
}
}
```
```typescript
// src/main/adapters/OneShotAdapter.ts (~80 LOC)
// Generic one-shot adapter configurable for Codex, Goose, Gemini, OpenCode
export interface OneShotConfig {
agentType: string;
subcommand?: string; // 'exec', 'run', etc.
outputFlag: string[]; // ['--json'], ['--output-format', 'stream-json'], etc.
promptFlag?: string; // '-p', '-t', or undefined for positional
mcpFlag?: string; // '--with-extension' for goose
killSignal?: NodeJS.Signals;
}
export class OneShotAdapter implements AgentAdapter {
readonly persistent = false;
readonly agentType: string;
readonly killSignal: NodeJS.Signals;
private config: OneShotConfig;
constructor(config: OneShotConfig) {
this.config = config;
this.agentType = config.agentType;
this.killSignal = config.killSignal ?? 'SIGTERM';
}
buildArgs(prompt: string, options) {
const args: string[] = [];
if (this.config.subcommand) args.push(this.config.subcommand);
args.push(...this.config.outputFlag);
if (options.mcpConfigPath && this.config.mcpFlag) {
args.push(this.config.mcpFlag, options.mcpConfigPath);
}
args.push(...(options.extraArgs ?? []));
if (this.config.promptFlag) {
args.push(this.config.promptFlag, prompt);
} else {
args.push(prompt); // positional
}
return args;
}
parseOutput(line: string): AgentOutput | null {
try {
const obj = JSON.parse(line);
return { type: 'raw', content: line, raw: obj };
} catch { return null; }
}
}
// Pre-built instances:
export const codexAdapter = new OneShotAdapter({
agentType: 'codex', subcommand: 'exec', outputFlag: ['--json'], killSignal: 'SIGTERM',
});
export const gooseAdapter = new OneShotAdapter({
agentType: 'goose', subcommand: 'run', outputFlag: ['--output-format', 'stream-json'],
promptFlag: '-t', mcpFlag: '--with-extension',
});
export const geminiAdapter = new OneShotAdapter({
agentType: 'gemini', outputFlag: ['--output-format', 'json'], promptFlag: '-p',
});
export const opencodeAdapter = new OneShotAdapter({
agentType: 'opencode', subcommand: 'run', outputFlag: ['--format', 'json'],
});
```
**Плюсы:**
- `parseOutput()` даёт место для нормализации вывода каждого CLI
- Чёткое разделение: Claude (persistent) vs all others (one-shot)
- `OneShotAdapter` — generic, покрывает 4 из 5 CLI одним классом
- Новый CLI = `new OneShotAdapter({ ... })` (одна строка)
**Минусы:**
- Интерфейс + 2 класса — чуть больше "архитектуры" чем нужно прямо сейчас
- `parseOutput()` для не-Claude CLI будет пустышкой (return raw) пока не изучим их NDJSON формат
- Всё ещё не решает MCP injection для Codex (config.toml) и Gemini (settings.json)
**Надёжность: 8/10** — Хороший баланс между простотой и расширяемостью.
**Уверенность: 7/10** — Interface-based подход стандартен, но `parseOutput` рискует стать "мёртвым кодом" на начальном этапе.
---
### Option C: Расширить childProcess.ts (минимальные изменения) **(Recommended)**
**~50 LOC additions** к существующему файлу + **~30 LOC** отдельный config
```typescript
// Добавить в src/main/utils/childProcess.ts (~25 LOC)
export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode';
export interface AgentSpawnResult {
child: ChildProcess;
send?: (text: string) => void;
kill: () => void;
}
/**
* Spawn any supported CLI agent. Thin wrapper over spawnCli that
* handles binary name, output-format flags, and prompt injection.
*/
export function spawnAgent(
type: AgentType,
binaryPath: string,
prompt: string,
options: SpawnOptions & { mcpConfigPath?: string; extraArgs?: string[] } = {}
): AgentSpawnResult {
const cfg = AGENT_SPAWN_CONFIGS[type];
const args = [...cfg.baseArgs];
if (options.mcpConfigPath && cfg.mcpFlag) {
args.push(cfg.mcpFlag, options.mcpConfigPath);
}
if (options.extraArgs) args.push(...options.extraArgs);
if (cfg.promptFlag) args.push(cfg.promptFlag, prompt);
else if (!cfg.stdinPrompt) args.push(prompt);
const child = spawnCli(binaryPath, args, {
...options,
env: { ...(options.env ?? process.env), ...(cfg.env ?? {}) },
stdio: ['pipe', 'pipe', 'pipe'],
});
// Inject prompt via stdin if needed
if (cfg.stdinPrompt && child.stdin?.writable) {
const msg = cfg.stdinPrompt === 'stream-json'
? JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } }) + '\n'
: prompt;
child.stdin.write(msg);
if (cfg.stdinPrompt === 'pipe') child.stdin.end();
}
return {
child,
send: cfg.stdinPrompt === 'stream-json'
? (text: string) => {
if (!child.stdin?.writable) return;
child.stdin.write(JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
}) + '\n');
}
: undefined,
kill: () => killProcessTree(child, cfg.killSignal),
};
}
```
```typescript
// src/main/utils/agentConfigs.ts (~30 LOC)
interface AgentSpawnConfig {
baseArgs: string[];
promptFlag?: string; // undefined = positional arg
stdinPrompt?: 'stream-json' | 'pipe';
mcpFlag?: string;
killSignal: NodeJS.Signals;
env?: Record<string, string>;
}
export const AGENT_SPAWN_CONFIGS: Record<string, AgentSpawnConfig> = {
claude: {
baseArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'],
stdinPrompt: 'stream-json',
mcpFlag: '--mcp-config',
killSignal: 'SIGKILL',
env: { CLAUDE_HOOK_JUDGE_MODE: 'true' },
},
codex: {
baseArgs: ['exec', '--json'],
killSignal: 'SIGTERM',
},
gemini: {
baseArgs: ['--output-format', 'json'],
promptFlag: '-p',
killSignal: 'SIGTERM',
},
goose: {
baseArgs: ['run', '--output-format', 'stream-json'],
promptFlag: '-t',
mcpFlag: '--with-extension',
killSignal: 'SIGTERM',
},
opencode: {
baseArgs: ['run', '--format', 'json'],
killSignal: 'SIGTERM',
},
};
```
**Плюсы:**
- Абсолютный минимум нового кода (~55 LOC)
- Не создаёт новую абстракцию — расширяет существующую
- TeamProvisioningService может постепенно мигрировать (или нет)
- Новый CLI = 5 строк в конфиге
- Binary resolution остаётся на вызывающей стороне (как сейчас с ClaudeBinaryResolver)
- Output parsing — ответственность вызывающего кода (не навязываем)
**Минусы:**
- Не покрывает output parsing (сознательно)
- Не покрывает MCP config injection для Codex/Gemini
- childProcess.ts станет чуть толще (~275 LOC вместо 221)
- Нет типизации вывода (каждый consumer парсит сам)
**Надёжность: 7/10** — Минимально, но достаточно для spawn.
**Уверенность: 9/10** — Расширение существующего утилитного файла — самый безопасный путь.
---
## 5. Сравнительная таблица
| Критерий | Option A (config+fn) | Option B (interface) | Option C (extend existing) |
|----------|---------------------|---------------------|---------------------------|
| **LOC** | ~120 | ~200 | ~55 |
| **Новых файлов** | 2 | 3 | 1 |
| **Output parsing** | Нет | Да (заглушка) | Нет |
| **MCP injection** | Описано, не реализовано | Описано, не реализовано | Описано, не реализовано |
| **Расширяемость** | Хорошая (конфиг) | Отличная (интерфейс) | Хорошая (конфиг) |
| **Breaks existing?** | Нет | Нет | Нет |
| **Time to implement** | 1 час | 2 часа | 30 мин |
| **"Велосипед"?** | Нет, это конфиг | Нет, но чуть преждевременно | Нет, это 55 строк клея |
---
## 6. Рекомендация
### Начать с Option C (extend childProcess.ts), при необходимости вырастить в Option A
**Почему:**
1. **55 LOC — это не велосипед.** Это минимальный config-driven dispatcher. Любой проект, интегрирующий несколько CLI, пишет ровно это. Нет смысла тянуть зависимость ради 55 строк.
2. **Output parsing — отдельная задача.** Парсинг NDJSON от Codex/Gemini/Goose — это ~50-100 LOC на каждый CLI, и его не нужно решать сейчас. Когда понадобится — это будет Option B (interface с `parseOutput()`), но не раньше.
3. **MCP injection — тоже отдельная задача.** Для Claude у нас уже есть TeamMcpConfigBuilder. Для Goose — это просто `--with-extension`. Для Codex/Gemini — нужно писать в их config files. Это 3 отдельных утилиты, не общий адаптер.
4. **Persistent vs one-shot — фундаментально разный lifecycle.** Claude (stream-json loop) живёт долго и получает новые сообщения. Все остальные — fire-and-forget. Эту разницу нельзя "спрятать" за единым интерфейсом без того чтобы интерфейс не стал дырявой абстракцией.
### Эволюционный путь:
```
Этап 1 (сейчас): Option C — spawnAgent() в childProcess.ts + agentConfigs.ts
55 LOC, покрывает spawn для всех 5 CLI
Этап 2 (когда добавим 2-й CLI): Вынести в отдельный файл если childProcess.ts станет перегруженным
Может стать Option A (~120 LOC)
Этап 3 (когда нужен output parsing): Добавить parseOutput() per agent
Может стать Option B (~200 LOC)
```
---
## 7. Честный ответ: "велосипед" или нет?
**Нет, это НЕ велосипед.** Вот почему:
1. **Нет готовой библиотеки.** Не существует npm-пакета "universal-cli-agent-spawner". Каждый из этих CLI — молодой продукт (2025-2026), с собственным протоколом. Никто ещё не написал унификатор.
2. **55-200 LOC клея — это норма.** Для сравнения:
- Docker SDK для Node.js: ~300 LOC для spawn docker CLI
- Terraform CDK: ~200 LOC для spawn terraform binary
- VS Code extensions: ~150 LOC для spawn language server
3. **Наш существующий spawnCli() — уже 65 LOC** клея для одного Claude CLI. Расширить его до 5 CLI за +55 LOC — это линейное масштабирование, не экспоненциальное.
4. **Реальный "велосипед" начался бы** если бы мы писали:
- Свой MCP client (~500+ LOC)
- Свой NDJSON parser с backpressure (~200 LOC)
- Свой process supervisor с restart policies (~400 LOC)
- Свой auth token manager per CLI (~300 LOC)
Мы этого НЕ делаем. Мы пишем config map + одну функцию.
5. **Большую часть сложности (8000 LOC TeamProvisioningService) мы уже написали** для Claude — и она Claude-specific. Адаптер для других CLI будет использовать ~5% от этого кода.
---
## 8. Что НЕ включать в адаптер
Явно НЕ входит в scope минимального адаптера:
- Output parsing/normalization (отдельный слой)
- Team protocol (Agent Teams — Claude-only)
- MCP config generation (отдельный builder per CLI)
- Binary auto-discovery/installation (отдельный resolver per CLI)
- Auth management (каждый CLI сам)
- Session persistence (каждый CLI сам)
- Stall/timeout detection (caller responsibility)
- Progress reporting (caller responsibility)
Это всё валидная функциональность, но она живёт ВЫШЕ адаптера, в orchestration layer (TeamProvisioningService или его аналог).