/** * PTY dispatch — runs external agents directly on the host. */ import type { FastifyBaseLogger } from 'fastify'; import { spawn } from 'node:child_process'; export interface DispatchResult { exitCode: number; stdout: string; stderr: string; } export interface PtyDispatchOpts { agent: string; task: string; worktreePath: string; model?: string; modeId?: string; thinkingOptionId?: string; installPath?: string; signal?: AbortSignal; log: FastifyBaseLogger; } interface PtySpawnSpec { binary: string; args: string[]; stdin?: string; } function buildPtySpawnSpec( agent: string, task: string, model?: string, modeId?: string, thinkingOptionId?: string, installPath?: string, ): PtySpawnSpec | null { const binary = installPath ?? agent; switch (agent) { case 'claude': { const args = ['-p']; if (model) args.push('--model', model); if (modeId) args.push('--permission-mode', modeId); if (thinkingOptionId) args.push('--effort', thinkingOptionId); return { binary, args, stdin: task }; } case 'qwen': { const args = ['-p', task, '--output-format', 'stream-json']; if (model) args.push('--model', model); if (modeId) args.push('--approval-mode', modeId); return { binary, args }; } case 'opencode': return { binary, args: model ? ['--model', model] : [], stdin: task, }; case 'goose': return { binary, args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task], }; default: return null; } } export async function dispatchViaPty(opts: PtyDispatchOpts): Promise { const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts; const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath); if (!cmd) { return { exitCode: 1, stdout: '', stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`, }; } log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting'); return new Promise((resolve, reject) => { const child = spawn(cmd.binary, cmd.args, { cwd: worktreePath, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, }); if (cmd.stdin) { child.stdin!.write(cmd.stdin); } child.stdin!.end(); let stdout = ''; let stderr = ''; let killed = false; child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); const cleanup = () => { if (!killed) { killed = true; child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 5_000); } }; if (signal) { if (signal.aborted) { cleanup(); resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start' }); return; } signal.addEventListener('abort', cleanup, { once: true }); } child.on('close', (code) => { if (signal) signal.removeEventListener('abort', cleanup); log.info({ agent, exitCode: code }, 'pty-dispatch: completed'); resolve({ exitCode: code ?? 1, stdout, stderr }); }); child.on('error', (err) => { if (signal) signal.removeEventListener('abort', cleanup); log.error({ agent, err: err.message }, 'pty-dispatch: spawn error'); reject(err); }); }); }