v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service) - Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec - SSH helpers marked @deprecated (kept for one release cycle) - Provider registry (5 providers: boocode, opencode, goose, claude, qwen) - Agent probe with direct which/exec + model discovery (qwen settings, static claude models) - GET /api/providers route with installed status, models, transport fallback - ProviderPicker frontend component in CoderPane header - External provider messages route through tasks row instead of inference enqueue - Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold) - DB: available_agents gets models, label, transport columns - Bug fix: loadContext SELECT includes allowed_read_paths - Bug fix: cap hit sentinel inserted before buildMessagesPayload - docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added - CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
This commit is contained in:
@@ -1,19 +1,18 @@
|
||||
/**
|
||||
* PTY dispatch — runs external agents on the host via SSH.
|
||||
* PTY dispatch — runs external agents directly on the host.
|
||||
*
|
||||
* For agents without ACP support (claude, pi), we pipe the task into their
|
||||
* non-interactive mode and capture stdout/stderr. The agent runs in a git
|
||||
* worktree so it can modify files freely.
|
||||
* v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
|
||||
* install_path from agent-probe. Follows Paseo's pattern: direct binary
|
||||
* path + args array + cwd.
|
||||
*
|
||||
* Supported agents:
|
||||
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
|
||||
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD)
|
||||
* - qwen: `qwen -p <task> --output-format stream-json` (NDJSON structured output)
|
||||
* - goose: stub (not yet supported)
|
||||
* - pi: stub (not yet supported)
|
||||
* - opencode: `opencode --model <model>` (stdin pipe)
|
||||
* - qwen: `qwen -p <task> --output-format stream-json`
|
||||
* - goose: `goose run --text <task>`
|
||||
*/
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { sshSpawnWithStdin } from './ssh.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface DispatchResult {
|
||||
exitCode: number;
|
||||
@@ -26,62 +25,61 @@ export interface PtyDispatchOpts {
|
||||
task: string;
|
||||
worktreePath: string;
|
||||
model?: string;
|
||||
installPath?: string;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shell command that runs the agent non-interactively.
|
||||
* The command will be executed inside `cd <worktreePath> && ...`.
|
||||
*/
|
||||
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
|
||||
// Escape the task for embedding in a shell command
|
||||
const escapedTask = task.replace(/'/g, "'\\''");
|
||||
interface AgentCommand {
|
||||
binary: string;
|
||||
args: string[];
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null {
|
||||
const binary = installPath ?? agent;
|
||||
|
||||
switch (agent) {
|
||||
case 'claude':
|
||||
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
|
||||
return model
|
||||
? `echo '${escapedTask}' | claude -p --model '${model}'`
|
||||
: `echo '${escapedTask}' | claude -p`;
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['-p', '--model', model] : ['-p'],
|
||||
stdin: task,
|
||||
};
|
||||
|
||||
case 'opencode':
|
||||
// opencode non-interactive: pipe task via stdin
|
||||
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
|
||||
return model
|
||||
? `echo '${escapedTask}' | opencode --model '${model}'`
|
||||
: `echo '${escapedTask}' | opencode`;
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['--model', model] : [],
|
||||
stdin: task,
|
||||
};
|
||||
|
||||
case 'qwen':
|
||||
// Qwen Code: structured JSON output mode for parseable events
|
||||
return model
|
||||
? `qwen -p '${escapedTask}' --model '${model}' --output-format stream-json`
|
||||
: `qwen -p '${escapedTask}' --output-format stream-json`;
|
||||
return {
|
||||
binary,
|
||||
args: model
|
||||
? ['-p', task, '--model', model, '--output-format', 'stream-json']
|
||||
: ['-p', task, '--output-format', 'stream-json'],
|
||||
};
|
||||
|
||||
case 'goose':
|
||||
// Not yet verified for non-interactive use
|
||||
return null;
|
||||
|
||||
case 'pi':
|
||||
// Not yet verified for non-interactive use
|
||||
return null;
|
||||
return {
|
||||
binary,
|
||||
args: model
|
||||
? ['run', '--text', task, '--model', model]
|
||||
: ['run', '--text', task],
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to an external agent via SSH.
|
||||
*
|
||||
* The agent runs in the worktree directory on the host. stdout/stderr are
|
||||
* captured in full and returned. The SSH process is killed on abort signal.
|
||||
*/
|
||||
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
||||
const { agent, task, worktreePath, model, signal, log } = opts;
|
||||
const { agent, task, worktreePath, model, installPath, signal, log } = opts;
|
||||
|
||||
const agentCmd = buildAgentCommand(agent, task, model);
|
||||
if (!agentCmd) {
|
||||
const cmd = buildAgentCommand(agent, task, model, installPath);
|
||||
if (!cmd) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
@@ -89,22 +87,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap in cd to the worktree
|
||||
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
|
||||
|
||||
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
|
||||
log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting');
|
||||
|
||||
return new Promise<DispatchResult>((resolve, reject) => {
|
||||
const child = sshSpawnWithStdin(fullCommand, '');
|
||||
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
|
||||
// stdin via echo piping, the command itself handles the piping on the remote
|
||||
// side. We just need the SSH tunnel.
|
||||
const child = spawn(cmd.binary, cmd.args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
|
||||
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
|
||||
// provides its own stdin. So we should use sshSpawn (no local stdin needed)
|
||||
// or just let the empty stdin close — the remote shell handles piping internally.
|
||||
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
|
||||
if (cmd.stdin) {
|
||||
child.stdin!.write(cmd.stdin);
|
||||
}
|
||||
child.stdin!.end();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -117,7 +112,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
// Give it a moment then force-kill
|
||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user