- 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.
29 KiB
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 fallbackexecCli(binaryPath, args, options)— exec для одноразовых командkillProcessTree(child, signal)— kill с Windows taskkill fallbackCLI_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):
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 —
codex exec --json, NDJSON events - Codex MCP Docs — config.toml based MCP
- Gemini CLI MCP Docs — settings.json,
gemini mcp add - Goose CLI Commands —
--output-format stream-json,--with-extension - Goose --output-format issue #4419 — json/stream-json Done
- OpenCode CLI Docs —
run --format json - OpenCode Agents Docs — 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
Что НЕ тривиально (требует адаптера):
- Stdin protocol — ТОЛЬКО Claude имеет persistent stdin loop (stream-json). Все остальные — one-shot (запустил, получил результат, процесс завершился). Это ФУНДАМЕНТАЛЬНОЕ отличие.
- Output parsing — NDJSON формат похож, но структура объектов разная. Claude:
{type: "assistant", message: {...}}. Codex: свой формат events. Goose: свой. Gemini: свой. - MCP config injection — Claude:
--mcp-config file.json. Codex: нужноcodex mcp addзаранее или config.toml. Gemini: нужноgemini mcp addили settings.json. Goose:--with-extensionper runtime.
Честная оценка: что из 8000 LOC TeamProvisioningService нужно для других CLI?
НЕ нужно (Claude-specific, 80% кода):
- stream-json stdin messaging loop
control_requestprotocol (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)
// 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,
},
};
// 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)
// 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;
}
// 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');
}
}
// 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
// Добавить в 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),
};
}
// 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
Почему:
-
55 LOC — это не велосипед. Это минимальный config-driven dispatcher. Любой проект, интегрирующий несколько CLI, пишет ровно это. Нет смысла тянуть зависимость ради 55 строк.
-
Output parsing — отдельная задача. Парсинг NDJSON от Codex/Gemini/Goose — это ~50-100 LOC на каждый CLI, и его не нужно решать сейчас. Когда понадобится — это будет Option B (interface с
parseOutput()), но не раньше. -
MCP injection — тоже отдельная задача. Для Claude у нас уже есть TeamMcpConfigBuilder. Для Goose — это просто
--with-extension. Для Codex/Gemini — нужно писать в их config files. Это 3 отдельных утилиты, не общий адаптер. -
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. Честный ответ: "велосипед" или нет?
Нет, это НЕ велосипед. Вот почему:
-
Нет готовой библиотеки. Не существует npm-пакета "universal-cli-agent-spawner". Каждый из этих CLI — молодой продукт (2025-2026), с собственным протоколом. Никто ещё не написал унификатор.
-
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
-
Наш существующий spawnCli() — уже 65 LOC клея для одного Claude CLI. Расширить его до 5 CLI за +55 LOC — это линейное масштабирование, не экспоненциальное.
-
Реальный "велосипед" начался бы если бы мы писали:
- Свой MCP client (~500+ LOC)
- Свой NDJSON parser с backpressure (~200 LOC)
- Свой process supervisor с restart policies (~400 LOC)
- Свой auth token manager per CLI (~300 LOC)
Мы этого НЕ делаем. Мы пишем config map + одну функцию.
-
Большую часть сложности (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 или его аналог).