🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

backbeat

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

backbeat - npm Package Compare versions

Package was removed
Sorry, it seems this package was removed from the registry
Comparing version
0.4.1
to
0.5.0
+23
dist/cli/commands/agents.d.ts
/**
* CLI commands: beat agents list | check | config
*
* ARCHITECTURE: Uses static AGENT_PROVIDERS for listing,
* checkAgentAuth() for auth status, and agent config storage for key management.
*/
export declare function listAgents(): Promise<void>;
/**
* beat agents check — show auth status for all agents
*/
export declare function checkAgents(): Promise<void>;
/**
* beat agents config set <agent> apiKey <value>
*/
export declare function agentsConfigSet(agent: string | undefined, key: string | undefined, value: string | undefined): Promise<void>;
/**
* beat agents config show [agent]
*/
export declare function agentsConfigShow(agent?: string): Promise<void>;
/**
* beat agents config reset <agent>
*/
export declare function agentsConfigReset(agent: string | undefined): Promise<void>;
/**
* CLI commands: beat agents list | check | config
*
* ARCHITECTURE: Uses static AGENT_PROVIDERS for listing,
* checkAgentAuth() for auth status, and agent config storage for key management.
*/
import { AGENT_AUTH, AGENT_DESCRIPTIONS, AGENT_PROVIDERS, checkAgentAuth, isAgentProvider, maskApiKey, } from '../../core/agents.js';
import { loadAgentConfig, loadConfiguration, resetAgentConfig, saveAgentConfig } from '../../core/configuration.js';
import * as ui from '../ui.js';
export async function listAgents() {
const config = loadConfiguration();
const lines = [];
for (const provider of AGENT_PROVIDERS) {
const suffix = provider === config.defaultAgent ? ' [default]' : '';
lines.push(`${provider.padEnd(10)} ${AGENT_DESCRIPTIONS[provider]}${suffix}`);
}
ui.note(lines.join('\n'), 'Available Agents');
if (!config.defaultAgent) {
ui.info('No default agent set. Run: beat init');
}
ui.info('Usage: beat run "prompt" --agent <name>');
process.exit(0);
}
/**
* beat agents check — show auth status for all agents
*/
export async function checkAgents() {
ui.step('Agent Auth Status');
const header = ` ${'Agent'.padEnd(10)} ${'CLI'.padEnd(8)} ${'Auth'.padEnd(40)} Status`;
ui.info(header);
for (const provider of AGENT_PROVIDERS) {
const agentConfig = loadAgentConfig(provider);
const status = checkAgentAuth(provider, agentConfig.apiKey);
const cliStatus = status.cliFound ? 'found' : '-';
let authDesc;
switch (status.method) {
case 'env-var': {
const key = status.envVar ? process.env[status.envVar] : undefined;
authDesc = `${status.envVar} set${key ? ` (${maskApiKey(key)})` : ''}`;
break;
}
case 'config-file':
authDesc = 'API key stored in config';
break;
case 'cli-installed':
authDesc = 'CLI installed (auth not verified)';
break;
default:
authDesc = 'not configured';
}
let badge;
if (status.method === 'cli-installed') {
badge = ui.yellow('[check auth]');
}
else if (status.ready) {
badge = ui.cyan('[ready]');
}
else {
badge = '[action needed]';
}
ui.step(`${provider.padEnd(10)} ${cliStatus.padEnd(8)} ${authDesc.padEnd(40)} ${badge}`);
if (status.hint && (status.method === 'cli-installed' || !status.ready)) {
const hintLines = status.hint.split('\n').slice(1); // Skip the header line
for (const line of hintLines) {
ui.info(` ${ui.dim(line)}`);
}
}
}
process.exit(0);
}
/**
* beat agents config set <agent> apiKey <value>
*/
export async function agentsConfigSet(agent, key, value) {
if (!agent || !key || !value) {
ui.error('Usage: beat agents config set <agent> apiKey <value>');
process.exit(1);
}
if (!isAgentProvider(agent)) {
ui.error(`Unknown agent: "${agent}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
if (key !== 'apiKey') {
ui.error(`Unknown config key: "${key}". Valid keys: apiKey`);
process.exit(1);
}
ui.note('API keys passed as CLI arguments may be stored in shell history. Consider using an environment variable instead.', 'Warning');
const result = saveAgentConfig(agent, key, value);
if (!result.ok) {
ui.error(result.error);
process.exit(1);
}
ui.success(`${agent}.${key} saved (${maskApiKey(value)})`);
process.exit(0);
}
/**
* beat agents config show [agent]
*/
export async function agentsConfigShow(agent) {
const providers = agent ? [agent] : [...AGENT_PROVIDERS];
const lines = [];
for (const p of providers) {
if (!isAgentProvider(p)) {
ui.error(`Unknown agent: "${p}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
const config = loadAgentConfig(p);
const auth = AGENT_AUTH[p];
if (config.apiKey) {
lines.push(`${p.padEnd(10)} apiKey: ${maskApiKey(config.apiKey)} (env var: ${auth.envVars[0]})`);
}
else {
lines.push(`${p.padEnd(10)} ${ui.dim('(no stored key)')}`);
}
}
ui.note(lines.join('\n'), 'Agent Configuration');
process.exit(0);
}
/**
* beat agents config reset <agent>
*/
export async function agentsConfigReset(agent) {
if (!agent) {
ui.error('Usage: beat agents config reset <agent>');
process.exit(1);
}
if (!isAgentProvider(agent)) {
ui.error(`Unknown agent: "${agent}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
const result = resetAgentConfig(agent);
if (!result.ok) {
ui.error(result.error);
process.exit(1);
}
ui.success(`${agent} config cleared`);
process.exit(0);
}
/**
* CLI command: beat init — Interactive first-time setup
*
* ARCHITECTURE: Three layers — types/DI, core logic (runInit), CLI entry (initCommand).
* All interactive prompts injected via InitDeps for testability without vi.mock().
* stderr-only output (stdout reserved for MCP protocol).
*/
import type { AgentAuthStatus, AgentProvider } from '../../core/agents.js';
export type InitResult = {
readonly code: 0;
readonly agent: AgentProvider;
readonly status: AgentAuthStatus;
} | {
readonly code: 0;
readonly reason: string;
} | {
readonly code: 1;
readonly reason: string;
};
export interface InitOptions {
readonly agent?: string;
readonly yes?: boolean;
}
export interface InitDeps {
readonly checkAuth: (provider: AgentProvider) => AgentAuthStatus;
readonly loadConfig: () => Record<string, unknown>;
readonly saveConfig: (key: string, value: unknown) => {
ok: true;
} | {
ok: false;
error: string;
};
readonly selectAgent: (statuses: readonly AgentAuthStatus[]) => Promise<AgentProvider | 'cancelled'>;
readonly confirmReconfigure: (existingAgent: AgentProvider) => Promise<boolean | 'cancelled'>;
readonly isTTY: boolean;
}
export declare function parseInitArgs(args: readonly string[]): InitOptions;
export declare function runInit(options: InitOptions, deps: InitDeps): Promise<InitResult>;
export declare function createDefaultDeps(): InitDeps;
export declare function initCommand(args: readonly string[]): Promise<void>;
/**
* CLI command: beat init — Interactive first-time setup
*
* ARCHITECTURE: Three layers — types/DI, core logic (runInit), CLI entry (initCommand).
* All interactive prompts injected via InitDeps for testability without vi.mock().
* stderr-only output (stdout reserved for MCP protocol).
*/
import * as p from '@clack/prompts';
import { AGENT_DESCRIPTIONS, AGENT_PROVIDERS, checkAgentAuth, isAgentProvider } from '../../core/agents.js';
import { CONFIG_FILE_PATH, loadAgentConfig, loadConfigFile, saveConfigValue } from '../../core/configuration.js';
import * as ui from '../ui.js';
// ============================================================================
// Arg Parsing
// ============================================================================
export function parseInitArgs(args) {
const options = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--agent=')) {
options.agent = arg.slice('--agent='.length) || undefined;
}
else if (arg === '--agent' || arg === '-a') {
const next = args[i + 1];
if (next && !next.startsWith('-')) {
options.agent = next;
i++;
}
}
else if (arg === '--yes' || arg === '-y') {
options.yes = true;
}
}
return options;
}
// ============================================================================
// Core Logic
// ============================================================================
export async function runInit(options, deps) {
const config = deps.loadConfig();
const existingAgent = typeof config.defaultAgent === 'string' && isAgentProvider(config.defaultAgent) ? config.defaultAgent : undefined;
// Non-interactive path: --agent flag
if (options.agent) {
if (!isAgentProvider(options.agent)) {
return { code: 1, reason: `Unknown agent: "${options.agent}". Available agents: ${AGENT_PROVIDERS.join(', ')}` };
}
const result = deps.saveConfig('defaultAgent', options.agent);
if (!result.ok) {
return { code: 1, reason: result.error };
}
const status = deps.checkAuth(options.agent);
return { code: 0, agent: options.agent, status };
}
// Non-TTY guard
if (!deps.isTTY) {
return { code: 1, reason: 'No TTY detected. Use: beat init --agent <agent>' };
}
// Interactive path: check if already configured
if (existingAgent && !options.yes) {
const confirmed = await deps.confirmReconfigure(existingAgent);
if (confirmed === 'cancelled') {
return { code: 0, reason: 'Setup cancelled.' };
}
if (!confirmed) {
return { code: 0, reason: 'Configuration unchanged.' };
}
}
// Detect auth status for all agents
const statuses = AGENT_PROVIDERS.map((provider) => deps.checkAuth(provider));
const selected = await deps.selectAgent(statuses);
if (selected === 'cancelled') {
return { code: 0, reason: 'Setup cancelled.' };
}
const result = deps.saveConfig('defaultAgent', selected);
if (!result.ok) {
return { code: 1, reason: result.error };
}
const status = statuses.find((s) => s.provider === selected);
if (!status) {
return { code: 1, reason: `Internal error: no auth status for '${selected}'` };
}
return { code: 0, agent: selected, status };
}
// ============================================================================
// Production Dependencies
// ============================================================================
function authHint(status) {
if (!status.ready)
return 'not configured';
switch (status.method) {
case 'env-var':
return 'ready (env var)';
case 'config-file':
return 'ready (config)';
case 'cli-installed':
return 'may need login';
default:
return 'not configured';
}
}
export function createDefaultDeps() {
return {
checkAuth(provider) {
const agentConfig = loadAgentConfig(provider);
return checkAgentAuth(provider, agentConfig.apiKey);
},
loadConfig: loadConfigFile,
saveConfig: saveConfigValue,
async selectAgent(statuses) {
// Sort: ready agents first
const sorted = [...statuses].sort((a, b) => {
if (a.ready && !b.ready)
return -1;
if (!a.ready && b.ready)
return 1;
return 0;
});
const result = await p.select({
message: 'Select your default AI agent:',
options: sorted.map((s) => ({
value: s.provider,
label: `${s.provider} — ${AGENT_DESCRIPTIONS[s.provider]}`,
hint: authHint(s),
})),
initialValue: sorted[0]?.provider,
output: process.stderr,
});
if (p.isCancel(result))
return 'cancelled';
return result;
},
async confirmReconfigure(existingAgent) {
const result = await p.confirm({
message: `Default agent is already set to '${existingAgent}'. Reconfigure?`,
output: process.stderr,
});
if (p.isCancel(result))
return 'cancelled';
return result;
},
isTTY: process.stderr.isTTY === true,
};
}
// ============================================================================
// CLI Entry
// ============================================================================
export async function initCommand(args) {
const options = parseInitArgs(args);
const deps = createDefaultDeps();
const isInteractive = deps.isTTY && !options.agent;
if (isInteractive) {
ui.intro('Backbeat Setup');
}
const result = await runInit(options, deps);
if (result.code === 1) {
ui.error(result.reason);
process.exit(1);
}
if ('agent' in result) {
if (isInteractive) {
if (result.status.hint) {
ui.info(result.status.hint);
}
ui.outro(`Default agent set to '${result.agent}'. Config: ${CONFIG_FILE_PATH}`);
}
else {
if (result.status.hint) {
ui.info(result.status.hint);
}
ui.success(`Default agent set to '${result.agent}'`);
}
}
else if (result.reason === 'Setup cancelled.') {
ui.cancel('Setup cancelled.');
}
else if (deps.isTTY) {
ui.outro(result.reason);
}
else {
ui.info(result.reason);
}
process.exit(0);
}
/**
* Core agent abstraction types for multi-agent support (v0.5.0)
*
* ARCHITECTURE: Defines the agent provider type system, adapter interface,
* and registry interface. All agent interactions go through these abstractions.
*
* Pattern: Discriminated union for providers, interface-based DI for adapters
* Rationale: Enables pluggable agent backends without changing core task logic
*/
import { ChildProcess } from 'child_process';
import { Result } from './result.js';
/**
* Supported agent providers
* Each provider corresponds to a CLI-based coding agent
*/
export type AgentProvider = 'claude' | 'codex' | 'gemini';
/**
* All valid agent providers as a Zod-compatible tuple
* Single source of truth — used by z.enum(), CLI, and iteration
*/
export declare const AGENT_PROVIDERS_TUPLE: [AgentProvider, ...AgentProvider[]];
/**
* All valid agent providers as a readonly array
* Used for validation and iteration
*/
export declare const AGENT_PROVIDERS: readonly AgentProvider[];
/**
* Resolve which agent to use for a task.
*
* Resolution order: explicit task agent → config default → error.
* Returns an actionable error when neither is set so the user
* knows exactly how to fix it.
*/
export declare function resolveDefaultAgent(taskAgent: AgentProvider | undefined, configDefault: AgentProvider | undefined): Result<AgentProvider>;
/**
* Type guard for validating agent provider strings
* Pattern: Parse, don't validate — used at system boundaries
*/
export declare function isAgentProvider(value: string): value is AgentProvider;
/**
* Human-readable descriptions for each agent provider
* Single source of truth — used by CLI, MCP adapter, and UI
*/
export declare const AGENT_DESCRIPTIONS: Readonly<Record<AgentProvider, string>>;
/**
* Auth requirements per agent provider — single source of truth
*
* ARCHITECTURE: Used by checkAgentAuth(), resolveAuth(), CLI `agents check`,
* and MCP ConfigureAgent/ListAgents tools. One definition, many consumers.
*/
export interface AgentAuthConfig {
/** Environment variable names that hold API keys */
readonly envVars: readonly string[];
/** CLI binary name (checked in PATH) */
readonly command: string;
/** Human-readable login instruction */
readonly loginHint: string;
/** Human-readable API key instruction */
readonly apiKeyHint: string;
}
export declare const AGENT_AUTH: Readonly<Record<AgentProvider, AgentAuthConfig>>;
/**
* Auth status for a single agent — reusable across CLI, MCP, and pre-spawn checks
*/
export interface AgentAuthStatus {
readonly provider: AgentProvider;
readonly ready: boolean;
readonly method: 'env-var' | 'config-file' | 'cli-installed' | 'none';
/** Which env var is set (if method is 'env-var') */
readonly envVar?: string;
/** Whether the CLI binary was found in PATH */
readonly cliFound: boolean;
/** Actionable fix hint (only when not ready) */
readonly hint?: string;
}
/**
* Check auth status for a given agent provider.
* Resolution order: env var → config file → CLI binary → not configured
*
* @param provider - Agent to check
* @param configApiKey - API key stored in config file (caller loads from configuration.ts)
* @param envOverride - Override for process.env (testing only)
*/
export declare function checkAgentAuth(provider: AgentProvider, configApiKey?: string, envOverride?: Record<string, string | undefined>): AgentAuthStatus;
/**
* Check if a command exists in PATH using `which`
* Separated for testability (can be mocked)
*/
export declare function isCommandInPath(command: string): boolean;
/**
* Mask an API key for display: show first 3 + last 3 chars
*/
export declare function maskApiKey(key: string): string;
/**
* Agent adapter interface — abstracts agent-specific CLI interactions
*
* ARCHITECTURE: Each agent implementation knows how to:
* 1. Build the correct CLI command and args
* 2. Strip environment variables that cause nesting issues
* 3. Spawn and manage the agent process
*
* Pattern: Strategy pattern — swap implementations without changing callers
*/
export interface AgentAdapter {
/** Which provider this adapter handles */
readonly provider: AgentProvider;
/**
* Spawn an agent process for the given prompt
* @param prompt - The task prompt to execute
* @param workingDirectory - Directory to run in
* @param taskId - Optional task ID for identification
* @returns Process handle with PID, or error
*/
spawn(prompt: string, workingDirectory: string, taskId?: string): Result<{
process: ChildProcess;
pid: number;
}>;
/**
* Kill an agent process by PID
* @param pid - Process ID to kill
* @returns Success or error
*/
kill(pid: number): Result<void>;
/**
* Clean up resources (kill timeouts, etc.)
*/
dispose(): void;
}
/**
* Agent registry — provides access to agent adapters by provider name
*
* ARCHITECTURE: Central lookup for agent adapters
* Pattern: Service locator scoped to agents only
* Rationale: WorkerPool resolves the correct adapter per task
*/
export interface AgentRegistry {
/**
* Get an adapter for the specified provider
* @returns The adapter, or error if provider not registered
*/
get(provider: AgentProvider): Result<AgentAdapter>;
/**
* Check if a provider is registered
*/
has(provider: AgentProvider): boolean;
/**
* List all registered provider names (sorted)
*/
list(): readonly AgentProvider[];
/**
* Clean up all adapter resources
*/
dispose(): void;
}
/**
* Core agent abstraction types for multi-agent support (v0.5.0)
*
* ARCHITECTURE: Defines the agent provider type system, adapter interface,
* and registry interface. All agent interactions go through these abstractions.
*
* Pattern: Discriminated union for providers, interface-based DI for adapters
* Rationale: Enables pluggable agent backends without changing core task logic
*/
import { spawnSync } from 'child_process';
import { BackbeatError, ErrorCode } from './errors.js';
import { err, ok } from './result.js';
/**
* All valid agent providers as a Zod-compatible tuple
* Single source of truth — used by z.enum(), CLI, and iteration
*/
export const AGENT_PROVIDERS_TUPLE = ['claude', 'codex', 'gemini'];
/**
* All valid agent providers as a readonly array
* Used for validation and iteration
*/
export const AGENT_PROVIDERS = Object.freeze(AGENT_PROVIDERS_TUPLE);
/**
* Resolve which agent to use for a task.
*
* Resolution order: explicit task agent → config default → error.
* Returns an actionable error when neither is set so the user
* knows exactly how to fix it.
*/
export function resolveDefaultAgent(taskAgent, configDefault) {
if (taskAgent)
return ok(taskAgent);
if (configDefault)
return ok(configDefault);
return err(new BackbeatError(ErrorCode.INVALID_INPUT, [
'No agent specified and no default agent configured.',
' Quick setup: beat init',
' Or set directly: beat config set defaultAgent <agent>',
` Available agents: ${AGENT_PROVIDERS.join(', ')}`,
' Or specify per-task: beat run --agent <agent> "prompt"',
].join('\n'), { field: 'agent' }));
}
/**
* Type guard for validating agent provider strings
* Pattern: Parse, don't validate — used at system boundaries
*/
export function isAgentProvider(value) {
return AGENT_PROVIDERS.includes(value);
}
/**
* Human-readable descriptions for each agent provider
* Single source of truth — used by CLI, MCP adapter, and UI
*/
export const AGENT_DESCRIPTIONS = Object.freeze({
claude: 'Claude Code (Anthropic)',
codex: 'Codex CLI (OpenAI)',
gemini: 'Gemini CLI (Google)',
});
export const AGENT_AUTH = Object.freeze({
claude: {
envVars: ['ANTHROPIC_API_KEY'],
command: 'claude',
loginHint: 'claude login',
apiKeyHint: 'export ANTHROPIC_API_KEY=<key>',
},
codex: {
envVars: ['OPENAI_API_KEY'],
command: 'codex',
loginHint: 'codex auth login',
apiKeyHint: 'export OPENAI_API_KEY=<key>',
},
gemini: {
envVars: ['GEMINI_API_KEY'],
command: 'gemini',
loginHint: 'gcloud auth application-default login',
apiKeyHint: 'export GEMINI_API_KEY=<key>',
},
});
/**
* Check auth status for a given agent provider.
* Resolution order: env var → config file → CLI binary → not configured
*
* @param provider - Agent to check
* @param configApiKey - API key stored in config file (caller loads from configuration.ts)
* @param envOverride - Override for process.env (testing only)
*/
export function checkAgentAuth(provider, configApiKey, envOverride) {
const auth = AGENT_AUTH[provider];
const env = envOverride ?? process.env;
// 1. Check env vars (explicit override, CI use case)
for (const envVar of auth.envVars) {
if (env[envVar]) {
return { provider, ready: true, method: 'env-var', envVar, cliFound: isCommandInPath(auth.command) };
}
}
// 2. Check config file for stored API key
if (configApiKey) {
return { provider, ready: true, method: 'config-file', cliFound: isCommandInPath(auth.command) };
}
// 3. Check CLI binary in PATH (login-based auth assumed)
if (isCommandInPath(auth.command)) {
return {
provider,
ready: true,
method: 'cli-installed',
cliFound: true,
hint: [
`Auth not verified. To confirm:`,
` 1. Log in: ${auth.loginHint}`,
` 2. Set API key: ${auth.apiKeyHint}`,
` 3. Store key: beat agents config set ${provider} apiKey <key>`,
].join('\n'),
};
}
// 4. Nothing configured
return {
provider,
ready: false,
method: 'none',
cliFound: false,
hint: [
`Agent '${provider}' not configured. Either:`,
` 1. Log in: ${auth.loginHint}`,
` 2. Set API key: ${auth.apiKeyHint}`,
` 3. Store key: beat agents config set ${provider} apiKey <key>`,
].join('\n'),
};
}
/**
* Check if a command exists in PATH using `which`
* Separated for testability (can be mocked)
*/
export function isCommandInPath(command) {
const result = spawnSync('which', [command], { stdio: 'ignore' });
return result.status === 0;
}
/**
* Mask an API key for display: show first 3 + last 3 chars
*/
export function maskApiKey(key) {
if (key.length <= 8)
return '***';
return `${key.slice(0, 3)}...${key.slice(-3)}`;
}
/**
* In-memory agent registry implementation
*
* ARCHITECTURE: Central lookup for agent adapters by provider name.
* Phase 1 registers only Claude; Phase 2 will register all configured agents.
*
* Pattern: Map-based registry with Result returns for safe lookup
*/
import { AgentAdapter, AgentProvider, AgentRegistry } from '../core/agents.js';
import { Result } from '../core/result.js';
export declare class InMemoryAgentRegistry implements AgentRegistry {
private readonly adapters;
constructor(adapters: readonly AgentAdapter[]);
get(provider: AgentProvider): Result<AgentAdapter>;
has(provider: AgentProvider): boolean;
list(): readonly AgentProvider[];
dispose(): void;
}
/**
* In-memory agent registry implementation
*
* ARCHITECTURE: Central lookup for agent adapters by provider name.
* Phase 1 registers only Claude; Phase 2 will register all configured agents.
*
* Pattern: Map-based registry with Result returns for safe lookup
*/
import { agentNotFound } from '../core/errors.js';
import { err, ok } from '../core/result.js';
export class InMemoryAgentRegistry {
adapters;
constructor(adapters) {
this.adapters = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
}
get(provider) {
const adapter = this.adapters.get(provider);
if (!adapter) {
const available = this.list();
return err(agentNotFound(provider, available));
}
return ok(adapter);
}
has(provider) {
return this.adapters.has(provider);
}
list() {
return Object.freeze([...this.adapters.keys()].sort());
}
dispose() {
for (const adapter of this.adapters.values()) {
adapter.dispose();
}
this.adapters.clear();
}
}
/**
* Base agent adapter — shared spawn/kill/dispose logic for all agent adapters
*
* ARCHITECTURE: All agent adapters share identical process lifecycle management
* (spawn, kill with SIGTERM->SIGKILL escalation, timeout tracking, dispose).
* Each subclass provides only:
* 1. The CLI command name
* 2. The CLI args for a given prompt
* 3. The env var prefixes to strip (prevents nesting issues)
* 4. Optional prompt transformation (e.g., Claude's short-prompt detection)
*
* Pattern: Template Method — shared algorithm, pluggable steps
*/
import { ChildProcess } from 'child_process';
import { AgentAdapter, AgentAuthConfig, AgentProvider } from '../core/agents.js';
import { Configuration } from '../core/configuration.js';
import { Result } from '../core/result.js';
export declare abstract class BaseAgentAdapter implements AgentAdapter {
protected readonly config: Configuration;
protected readonly command: string;
abstract readonly provider: AgentProvider;
private readonly killTimeouts;
constructor(config: Configuration, command: string);
/** Build CLI args for the given prompt */
protected abstract buildArgs(prompt: string): readonly string[];
/** Env var prefixes to strip before spawning (prevents nesting issues) */
protected abstract get envPrefixesToStrip(): readonly string[];
/** Env var exact names to strip (matched with === instead of startsWith) */
protected get envExactMatchesToStrip(): readonly string[];
/**
* Optional prompt transformation before passing to the CLI.
* Override in subclasses that need prompt preprocessing.
* Default: returns prompt unchanged.
*/
protected transformPrompt(prompt: string): string;
/** Auth config for this agent's provider */
protected get authConfig(): AgentAuthConfig;
/**
* Resolve authentication before spawn.
* Resolution order: env var → config file → CLI login (assumed)
*
* NOTE: spawn() verifies CLI binary exists before calling resolveAuth(),
* so step 3 safely assumes login-based auth if no explicit key is configured.
*
* @returns Additional env vars to inject (e.g., stored API key), or error
*/
protected resolveAuth(): Result<{
injectedEnv: Record<string, string>;
}>;
/** Additional env vars to inject into the spawned process (override in subclasses) */
protected get additionalEnv(): Record<string, string>;
spawn(prompt: string, workingDirectory: string, taskId?: string): Result<{
process: ChildProcess;
pid: number;
}>;
kill(pid: number): Result<void>;
dispose(): void;
private clearKillTimeout;
}
/**
* Base agent adapter — shared spawn/kill/dispose logic for all agent adapters
*
* ARCHITECTURE: All agent adapters share identical process lifecycle management
* (spawn, kill with SIGTERM->SIGKILL escalation, timeout tracking, dispose).
* Each subclass provides only:
* 1. The CLI command name
* 2. The CLI args for a given prompt
* 3. The env var prefixes to strip (prevents nesting issues)
* 4. Optional prompt transformation (e.g., Claude's short-prompt detection)
*
* Pattern: Template Method — shared algorithm, pluggable steps
*/
import { spawn } from 'child_process';
import { AGENT_AUTH, isCommandInPath } from '../core/agents.js';
import { loadAgentConfig } from '../core/configuration.js';
import { agentMisconfigured, BackbeatError, ErrorCode, processSpawnFailed } from '../core/errors.js';
import { err, ok, tryCatch } from '../core/result.js';
export class BaseAgentAdapter {
config;
command;
killTimeouts = new Map();
constructor(config, command) {
this.config = config;
this.command = command;
}
/** Env var exact names to strip (matched with === instead of startsWith) */
get envExactMatchesToStrip() {
return [];
}
/**
* Optional prompt transformation before passing to the CLI.
* Override in subclasses that need prompt preprocessing.
* Default: returns prompt unchanged.
*/
transformPrompt(prompt) {
return prompt;
}
/** Auth config for this agent's provider */
get authConfig() {
return AGENT_AUTH[this.provider];
}
/**
* Resolve authentication before spawn.
* Resolution order: env var → config file → CLI login (assumed)
*
* NOTE: spawn() verifies CLI binary exists before calling resolveAuth(),
* so step 3 safely assumes login-based auth if no explicit key is configured.
*
* @returns Additional env vars to inject (e.g., stored API key), or error
*/
resolveAuth() {
const auth = this.authConfig;
// 1. Check env vars (explicit override, CI use case)
for (const envVar of auth.envVars) {
if (process.env[envVar]) {
return ok({ injectedEnv: {} });
}
}
// 2. Check config file for stored API key
const agentConfig = loadAgentConfig(this.provider);
if (agentConfig.apiKey) {
// Inject stored key as the first env var for this agent
return ok({ injectedEnv: { [auth.envVars[0]]: agentConfig.apiKey } });
}
// 3. CLI binary already verified in spawn() — assume login-based auth
return ok({ injectedEnv: {} });
}
/** Additional env vars to inject into the spawned process (override in subclasses) */
get additionalEnv() {
return {};
}
spawn(prompt, workingDirectory, taskId) {
try {
// Pre-spawn: verify CLI binary exists before anything else
if (!isCommandInPath(this.command)) {
return err(agentMisconfigured(this.provider, [`CLI binary '${this.command}' not found in PATH.`, ` Install: ${this.authConfig.loginHint}`].join('\n')));
}
// Pre-spawn auth validation
const authResult = this.resolveAuth();
if (!authResult.ok)
return authResult;
const finalPrompt = this.transformPrompt(prompt);
const args = this.buildArgs(finalPrompt);
const exactMatches = this.envExactMatchesToStrip;
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !this.envPrefixesToStrip.some((prefix) => key.startsWith(prefix)) && !exactMatches.includes(key)));
const env = {
...this.additionalEnv,
...cleanEnv,
...authResult.value.injectedEnv,
BACKBEAT_WORKER: 'true',
...(taskId && { BACKBEAT_TASK_ID: taskId }),
};
const child = spawn(this.command, [...args], {
cwd: workingDirectory,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!child.pid) {
return err(processSpawnFailed('Failed to get process PID'));
}
return ok({ process: child, pid: child.pid });
}
catch (error) {
return err(processSpawnFailed(String(error)));
}
}
kill(pid) {
return tryCatch(() => {
this.clearKillTimeout(pid);
process.kill(pid, 'SIGTERM');
const timeoutId = setTimeout(() => {
try {
process.kill(pid, 'SIGKILL');
}
catch {
// Process might already be dead
}
finally {
this.killTimeouts.delete(pid);
}
}, this.config.killGracePeriodMs);
this.killTimeouts.set(pid, timeoutId);
}, (error) => new BackbeatError(ErrorCode.PROCESS_KILL_FAILED, `Failed to kill process ${pid}: ${error}`, { pid, error }));
}
dispose() {
for (const [, timeoutId] of this.killTimeouts) {
clearTimeout(timeoutId);
}
this.killTimeouts.clear();
}
clearKillTimeout(pid) {
const timeoutId = this.killTimeouts.get(pid);
if (timeoutId) {
clearTimeout(timeoutId);
this.killTimeouts.delete(pid);
}
}
}
/**
* Claude Code agent adapter implementation
*
* ARCHITECTURE: Claude-specific logic on top of BaseAgentAdapter.
* Includes prompt transformation for short commands and nesting prevention.
*/
import { AgentProvider } from '../core/agents.js';
import { Configuration } from '../core/configuration.js';
import { BaseAgentAdapter } from './base-agent-adapter.js';
export declare class ClaudeAdapter extends BaseAgentAdapter {
readonly provider: AgentProvider;
private readonly baseArgs;
constructor(config: Configuration, claudeCommand?: string);
protected buildArgs(prompt: string): readonly string[];
protected get envPrefixesToStrip(): readonly string[];
protected get envExactMatchesToStrip(): readonly string[];
}
/**
* Claude Code agent adapter implementation
*
* ARCHITECTURE: Claude-specific logic on top of BaseAgentAdapter.
* Includes prompt transformation for short commands and nesting prevention.
*/
import { BaseAgentAdapter } from './base-agent-adapter.js';
export class ClaudeAdapter extends BaseAgentAdapter {
provider = 'claude';
baseArgs;
constructor(config, claudeCommand = 'claude') {
super(config, claudeCommand);
this.baseArgs = Object.freeze(['--print', '--dangerously-skip-permissions', '--output-format', 'json']);
}
buildArgs(prompt) {
return [...this.baseArgs, '--', prompt];
}
get envPrefixesToStrip() {
// Strip CLAUDE_CODE_* prefix vars (e.g., CLAUDE_CODE_ENTRYPOINT)
return ['CLAUDE_CODE_'];
}
get envExactMatchesToStrip() {
// Exact match for CLAUDECODE — avoids over-stripping CLAUDECODE_SESSION etc.
return ['CLAUDECODE'];
}
}
/**
* OpenAI Codex CLI agent adapter implementation
*
* ARCHITECTURE: Codex-specific CLI flags on top of BaseAgentAdapter.
* Uses --quiet and --full-auto for non-interactive execution.
*/
import { AgentProvider } from '../core/agents.js';
import { Configuration } from '../core/configuration.js';
import { BaseAgentAdapter } from './base-agent-adapter.js';
export declare class CodexAdapter extends BaseAgentAdapter {
readonly provider: AgentProvider;
constructor(config: Configuration, codexCommand?: string);
protected buildArgs(prompt: string): readonly string[];
protected get envPrefixesToStrip(): readonly string[];
}
/**
* OpenAI Codex CLI agent adapter implementation
*
* ARCHITECTURE: Codex-specific CLI flags on top of BaseAgentAdapter.
* Uses --quiet and --full-auto for non-interactive execution.
*/
import { BaseAgentAdapter } from './base-agent-adapter.js';
export class CodexAdapter extends BaseAgentAdapter {
provider = 'codex';
constructor(config, codexCommand = 'codex') {
super(config, codexCommand);
}
buildArgs(prompt) {
return ['--quiet', '--full-auto', '--', prompt];
}
get envPrefixesToStrip() {
// ARCHITECTURE: No known Codex CLI nesting indicators.
// Auth uses OPENAI_API_KEY (not CODEX_*), so stripping is unnecessary.
return [];
}
}
/**
* Google Gemini CLI agent adapter implementation
*
* ARCHITECTURE: Gemini-specific CLI flags on top of BaseAgentAdapter.
* Uses --prompt for non-interactive (headless) mode and --yolo for auto-accept.
*/
import { AgentProvider } from '../core/agents.js';
import { Configuration } from '../core/configuration.js';
import { BaseAgentAdapter } from './base-agent-adapter.js';
export declare class GeminiAdapter extends BaseAgentAdapter {
readonly provider: AgentProvider;
constructor(config: Configuration, geminiCommand?: string);
protected buildArgs(prompt: string): readonly string[];
protected get additionalEnv(): Record<string, string>;
protected get envPrefixesToStrip(): readonly string[];
}
/**
* Google Gemini CLI agent adapter implementation
*
* ARCHITECTURE: Gemini-specific CLI flags on top of BaseAgentAdapter.
* Uses --prompt for non-interactive (headless) mode and --yolo for auto-accept.
*/
import { BaseAgentAdapter } from './base-agent-adapter.js';
export class GeminiAdapter extends BaseAgentAdapter {
provider = 'gemini';
constructor(config, geminiCommand = 'gemini') {
super(config, geminiCommand);
}
buildArgs(prompt) {
return ['--yolo', '--prompt', prompt];
}
get additionalEnv() {
// --yolo enables Docker sandbox by default; disable it so Docker/Podman isn't required.
// Users who want sandbox can set GEMINI_SANDBOX=true in their environment.
return { GEMINI_SANDBOX: 'false' };
}
get envPrefixesToStrip() {
// ARCHITECTURE: No known Gemini CLI nesting indicators.
// IMPORTANT: Must NOT strip GEMINI_API_KEY — required for authentication.
return [];
}
}
/**
* Compatibility adapter: wraps a ProcessSpawner as an AgentAdapter
*
* ARCHITECTURE: Enables backward compatibility during the migration from
* ProcessSpawner to AgentAdapter. Used when a ProcessSpawner is injected
* via BootstrapOptions (e.g., for tests using MockProcessSpawner).
*
* This adapter will be removed once all tests migrate to mock AgentAdapters.
*/
import { ChildProcess } from 'child_process';
import { AgentAdapter, AgentProvider } from '../core/agents.js';
import { ProcessSpawner } from '../core/interfaces.js';
import { Result } from '../core/result.js';
export declare class ProcessSpawnerAdapter implements AgentAdapter {
private readonly spawner;
readonly provider: AgentProvider;
constructor(spawner: ProcessSpawner, provider?: AgentProvider);
spawn(prompt: string, workingDirectory: string, taskId?: string): Result<{
process: ChildProcess;
pid: number;
}>;
kill(pid: number): Result<void>;
dispose(): void;
}
/**
* Compatibility adapter: wraps a ProcessSpawner as an AgentAdapter
*
* ARCHITECTURE: Enables backward compatibility during the migration from
* ProcessSpawner to AgentAdapter. Used when a ProcessSpawner is injected
* via BootstrapOptions (e.g., for tests using MockProcessSpawner).
*
* This adapter will be removed once all tests migrate to mock AgentAdapters.
*/
export class ProcessSpawnerAdapter {
spawner;
provider;
constructor(spawner, provider = 'claude') {
this.spawner = spawner;
this.provider = provider;
}
spawn(prompt, workingDirectory, taskId) {
return this.spawner.spawn(prompt, workingDirectory, taskId);
}
kill(pid) {
return this.spawner.kill(pid);
}
dispose() {
// ProcessSpawner may or may not have dispose
if ('dispose' in this.spawner && typeof this.spawner.dispose === 'function') {
this.spawner.dispose();
}
}
}
+15
-1

@@ -6,2 +6,4 @@ /**

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { AgentRegistry } from '../core/agents.js';
import { type Configuration } from '../core/configuration.js';
import { Logger, ScheduleService, TaskManager } from '../core/interfaces.js';

@@ -12,4 +14,6 @@ export declare class MCPAdapter {

private readonly scheduleService;
private readonly agentRegistry;
private readonly config;
private server;
constructor(taskManager: TaskManager, logger: Logger, scheduleService: ScheduleService);
constructor(taskManager: TaskManager, logger: Logger, scheduleService: ScheduleService, agentRegistry: AgentRegistry | undefined, config: Configuration);
/**

@@ -61,2 +65,12 @@ * Get the MCP server instance for starting

private handleCreatePipeline;
/**
* Handle ListAgents tool call
* Returns all known agent providers with registration and auth status
*/
private handleListAgents;
/**
* Handle ConfigureAgent tool call
* Actions: check auth status, set API key, reset stored key
*/
private handleConfigureAgent;
}

@@ -8,2 +8,4 @@ /**

import pkg from '../../package.json' with { type: 'json' };
import { AGENT_DESCRIPTIONS, AGENT_PROVIDERS, AGENT_PROVIDERS_TUPLE, checkAgentAuth, maskApiKey, } from '../core/agents.js';
import { loadAgentConfig, resetAgentConfig, saveAgentConfig } from '../core/configuration.js';
import { ScheduleId, ScheduleType, TaskId, } from '../core/domain.js';

@@ -26,2 +28,6 @@ import { match } from '../core/result.js';

.describe('Task ID to continue from — receives checkpoint context from this dependency (must be in dependsOn list)'),
agent: z
.enum(AGENT_PROVIDERS_TUPLE)
.optional()
.describe('AI agent to execute the task (uses configured default if omitted)'),
});

@@ -62,2 +68,6 @@ const TaskStatusSchema = z.object({

.describe("Schedule ID to chain after (new tasks depend on this schedule's latest task)"),
agent: z
.enum(AGENT_PROVIDERS_TUPLE)
.optional()
.describe('AI agent to execute the task (uses configured default if omitted)'),
});

@@ -90,2 +100,3 @@ const ListSchedulesSchema = z.object({

workingDirectory: z.string().optional().describe('Working directory override (absolute path)'),
agent: z.enum(AGENT_PROVIDERS_TUPLE).optional().describe('Agent override for this step'),
}))

@@ -103,3 +114,15 @@ .min(2, 'Pipeline requires at least 2 steps')

.describe('Default working directory for all steps (individual steps can override)'),
agent: z
.enum(AGENT_PROVIDERS_TUPLE)
.optional()
.describe('Default agent for all steps (individual steps can override)'),
});
const ConfigureAgentSchema = z.object({
agent: z.enum(AGENT_PROVIDERS_TUPLE).describe('Agent provider to configure'),
action: z
.enum(['set', 'check', 'reset'])
.default('check')
.describe('Action: set API key, check auth status, or reset stored key'),
apiKey: z.string().min(1).optional().describe('API key to store (required for set action)'),
});
export class MCPAdapter {

@@ -109,7 +132,11 @@ taskManager;

scheduleService;
agentRegistry;
config;
server;
constructor(taskManager, logger, scheduleService) {
constructor(taskManager, logger, scheduleService, agentRegistry, config) {
this.taskManager = taskManager;
this.logger = logger;
this.scheduleService = scheduleService;
this.agentRegistry = agentRegistry;
this.config = config;
this.server = new Server({

@@ -174,2 +201,6 @@ name: 'backbeat',

return await this.handleCreatePipeline(args);
case 'ListAgents':
return this.handleListAgents();
case 'ConfigureAgent':
return this.handleConfigureAgent(args);
default:

@@ -244,2 +275,7 @@ // ARCHITECTURE: Return error response instead of throwing

},
agent: {
type: 'string',
enum: [...AGENT_PROVIDERS],
description: `AI agent to execute the task (${this.config.defaultAgent ? `default: ${this.config.defaultAgent}` : 'required if no default configured'})`,
},
},

@@ -392,2 +428,7 @@ required: ['prompt'],

},
agent: {
type: 'string',
enum: [...AGENT_PROVIDERS],
description: `AI agent to execute the task (${this.config.defaultAgent ? `default: ${this.config.defaultAgent}` : 'required if no default configured'})`,
},
},

@@ -509,2 +550,7 @@ required: ['prompt', 'scheduleType'],

},
agent: {
type: 'string',
enum: [...AGENT_PROVIDERS],
description: 'Agent override for this step',
},
},

@@ -525,2 +571,7 @@ required: ['prompt'],

},
agent: {
type: 'string',
enum: [...AGENT_PROVIDERS],
description: 'Default agent for all steps (individual steps can override)',
},
},

@@ -530,2 +581,35 @@ required: ['steps'],

},
// Agent tools (v0.5.0 Multi-Agent Support)
{
name: 'ListAgents',
description: 'List available AI agents with registration and auth status',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'ConfigureAgent',
description: 'Check auth status, store API key, or reset stored key for an agent',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
enum: [...AGENT_PROVIDERS],
description: 'Agent provider to configure',
},
action: {
type: 'string',
enum: ['set', 'check', 'reset'],
description: 'Action to perform (default: check)',
},
apiKey: {
type: 'string',
description: 'API key to store (required for set action)',
},
},
required: ['agent'],
},
},
],

@@ -576,2 +660,3 @@ };

continueFrom: data.continueFrom ? TaskId(data.continueFrom) : undefined,
agent: data.agent,
};

@@ -656,2 +741,3 @@ // Delegate task using our new architecture

workingDirectory: task.workingDirectory,
agent: task.agent ?? 'unknown',
}),

@@ -873,2 +959,3 @@ },

afterScheduleId: data.afterSchedule ? ScheduleId(data.afterSchedule) : undefined,
agent: data.agent,
};

@@ -1150,2 +1237,3 @@ const result = await this.scheduleService.createSchedule(request);

workingDirectory: s.workingDirectory,
agent: (s.agent ?? data.agent),
})),

@@ -1180,2 +1268,121 @@ priority: data.priority,

}
// ============================================================================
// AGENT HANDLERS (v0.5.0 Multi-Agent Support)
// ============================================================================
/**
* Handle ListAgents tool call
* Returns all known agent providers with registration and auth status
*/
handleListAgents() {
const agents = AGENT_PROVIDERS.map((provider) => {
const agentConfig = loadAgentConfig(provider);
const authStatus = checkAgentAuth(provider, agentConfig.apiKey);
return {
provider,
description: AGENT_DESCRIPTIONS[provider],
registered: this.agentRegistry?.has(provider) ?? false,
isDefault: provider === this.config.defaultAgent,
authStatus: authStatus.ready ? 'ready' : 'not-configured',
authMethod: authStatus.method,
...(authStatus.hint && { hint: authStatus.hint }),
};
});
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
agents,
defaultAgent: this.config.defaultAgent ?? null,
}, null, 2),
},
],
};
}
/**
* Handle ConfigureAgent tool call
* Actions: check auth status, set API key, reset stored key
*/
handleConfigureAgent(args) {
const parseResult = ConfigureAgentSchema.safeParse(args);
if (!parseResult.success) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
error: parseResult.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '),
}, null, 2),
},
],
isError: true,
};
}
const { agent, action, apiKey } = parseResult.data;
switch (action) {
case 'check': {
const agentConfig = loadAgentConfig(agent);
const status = checkAgentAuth(agent, agentConfig.apiKey);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
...status,
...(agentConfig.apiKey && { storedKey: maskApiKey(agentConfig.apiKey) }),
}, null, 2),
},
],
};
}
case 'set': {
if (!apiKey) {
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: false, error: 'apiKey is required for set action' }, null, 2),
},
],
isError: true,
};
}
const result = saveAgentConfig(agent, 'apiKey', apiKey);
if (!result.ok) {
return {
content: [{ type: 'text', text: JSON.stringify({ success: false, error: result.error }, null, 2) }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, message: `API key stored for ${agent} (${maskApiKey(apiKey)})` }, null, 2),
},
],
};
}
case 'reset': {
const result = resetAgentConfig(agent);
if (!result.ok) {
return {
content: [{ type: 'text', text: JSON.stringify({ success: false, error: result.error }, null, 2) }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, message: `Stored config cleared for ${agent}` }, null, 2),
},
],
};
}
}
}
}
+28
-9

@@ -11,8 +11,13 @@ /**

import { err, ok } from './core/result.js';
// Adapter
// Adapters
import { MCPAdapter } from './adapters/mcp-adapter.js';
// Implementations
import { InMemoryAgentRegistry } from './implementations/agent-registry.js';
import { SQLiteCheckpointRepository } from './implementations/checkpoint-repository.js';
import { ClaudeAdapter } from './implementations/claude-adapter.js';
import { CodexAdapter } from './implementations/codex-adapter.js';
import { Database } from './implementations/database.js';
import { SQLiteDependencyRepository } from './implementations/dependency-repository.js';
import { EventDrivenWorkerPool } from './implementations/event-driven-worker-pool.js';
import { GeminiAdapter } from './implementations/gemini-adapter.js';
import { ConsoleLogger, LogLevel, StructuredLogger } from './implementations/logger.js';

@@ -22,15 +27,13 @@ import { BufferedOutputCapture } from './implementations/output-capture.js';

import { ClaudeProcessSpawner } from './implementations/process-spawner.js';
import { ProcessSpawnerAdapter } from './implementations/process-spawner-adapter.js';
import { SystemResourceMonitor } from './implementations/resource-monitor.js';
import { SQLiteScheduleRepository } from './implementations/schedule-repository.js';
// Implementations
import { PriorityTaskQueue } from './implementations/task-queue.js';
import { SQLiteTaskRepository } from './implementations/task-repository.js';
// Services
import { AutoscalingManager } from './services/autoscaling-manager.js';
// Handler Setup (extracts handler creation from bootstrap)
import { extractHandlerDependencies, setupEventHandlers } from './services/handler-setup.js';
import { RecoveryManager } from './services/recovery-manager.js';
// Schedule Executor
import { ScheduleExecutor } from './services/schedule-executor.js';
import { ScheduleManagerService } from './services/schedule-manager.js';
// Services
import { TaskManagerService } from './services/task-manager.js';

@@ -192,3 +195,3 @@ // Convert new configuration format to existing Config interface

container.registerSingleton('scheduleService', () => {
return new ScheduleManagerService(getFromContainer(container, 'eventBus'), getFromContainer(container, 'logger').child({ module: 'ScheduleManager' }), getFromContainer(container, 'scheduleRepository'));
return new ScheduleManagerService(getFromContainer(container, 'eventBus'), getFromContainer(container, 'logger').child({ module: 'ScheduleManager' }), getFromContainer(container, 'scheduleRepository'), config);
});

@@ -208,2 +211,18 @@ // Register core services

});
// Register AgentRegistry for multi-agent support (v0.5.0)
// ARCHITECTURE: If a custom ProcessSpawner is injected (tests), wrap it in a
// compatibility adapter. Otherwise, register all 4 agent adapters.
container.registerSingleton('agentRegistry', () => {
if (options.processSpawner) {
logger.info('Using ProcessSpawnerAdapter for injected ProcessSpawner');
const adapter = new ProcessSpawnerAdapter(options.processSpawner);
return new InMemoryAgentRegistry([adapter]);
}
const configResult = container.get('config');
if (!configResult.ok)
throw new Error('Config required for AgentRegistry');
const cfg = configResult.value;
const adapters = [new ClaudeAdapter(cfg), new CodexAdapter(cfg), new GeminiAdapter(cfg)];
return new InMemoryAgentRegistry(adapters);
});
container.registerSingleton('resourceMonitor', () => {

@@ -236,5 +255,5 @@ // Use provided resourceMonitor if given (e.g., TestResourceMonitor for tests)

});
// Register worker pool
// Register worker pool (v0.5.0: uses AgentRegistry instead of ProcessSpawner)
container.registerSingleton('workerPool', () => {
const pool = new EventDrivenWorkerPool(getFromContainer(container, 'processSpawner'), getFromContainer(container, 'resourceMonitor'), getFromContainer(container, 'logger').child({ module: 'WorkerPool' }), getFromContainer(container, 'eventBus'), getFromContainer(container, 'outputCapture'));
const pool = new EventDrivenWorkerPool(getFromContainer(container, 'agentRegistry'), getFromContainer(container, 'resourceMonitor'), getFromContainer(container, 'logger').child({ module: 'WorkerPool' }), getFromContainer(container, 'eventBus'), getFromContainer(container, 'outputCapture'));
return pool;

@@ -279,3 +298,3 @@ });

}
return new MCPAdapter(taskManagerResult.value, getFromContainer(container, 'logger').child({ module: 'MCP' }), getFromContainer(container, 'scheduleService'));
return new MCPAdapter(taskManagerResult.value, getFromContainer(container, 'logger').child({ module: 'MCP' }), getFromContainer(container, 'scheduleService'), getFromContainer(container, 'agentRegistry'), config);
});

@@ -282,0 +301,0 @@ // Register recovery manager

@@ -7,5 +7,7 @@ #!/usr/bin/env node

import { fileURLToPath } from 'url';
import { agentsConfigReset, agentsConfigSet, agentsConfigShow, checkAgents, listAgents, } from './cli/commands/agents.js';
import { cancelTask } from './cli/commands/cancel.js';
import { configPath, configReset, configSet, configShow } from './cli/commands/config.js';
import { showHelp } from './cli/commands/help.js';
import { initCommand } from './cli/commands/init.js';
import { getTaskLogs } from './cli/commands/logs.js';

@@ -20,2 +22,3 @@ import { handleMcpStart, handleMcpTest, showConfig } from './cli/commands/mcp.js';

import * as ui from './cli/ui.js';
import { AGENT_PROVIDERS, isAgentProvider } from './core/agents.js';
import { validateBufferSize, validatePath, validateTimeout } from './utils/validation.js';

@@ -135,2 +138,17 @@ const __filename = fileURLToPath(import.meta.url);

}
else if (arg === '--agent' || arg === '-a') {
const next = foregroundArgs[i + 1];
if (next && !next.startsWith('-')) {
if (!isAgentProvider(next)) {
ui.error(`Unknown agent: "${next}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
options.agent = next;
i++;
}
else {
ui.error(`--agent requires an agent name (${AGENT_PROVIDERS.join(', ')})`);
process.exit(1);
}
}
else if (arg.startsWith('-')) {

@@ -152,2 +170,3 @@ ui.error(`Unknown flag: ${arg}`);

' -w, --working-directory DIR Working directory for task execution',
' -a, --agent AGENT AI agent to use (claude, codex, gemini)',
' -t, --timeout MS Task timeout in milliseconds',

@@ -159,2 +178,3 @@ ' --max-output-buffer BYTES Maximum output buffer size',

' beat run "quick fix" --foreground # Stream output, wait',
' beat run "analyze code" --agent codex # Use Codex instead of Claude',
'',

@@ -222,2 +242,31 @@ ].join('\n'));

}
else if (mainCommand === 'agents') {
if (subCommand === 'list' || !subCommand) {
await listAgents();
}
else if (subCommand === 'check') {
await checkAgents();
}
else if (subCommand === 'config') {
const configAction = args[2];
if (configAction === 'set') {
await agentsConfigSet(args[3], args[4], args[5]);
}
else if (configAction === 'show') {
await agentsConfigShow(args[3]); // optional agent filter
}
else if (configAction === 'reset') {
await agentsConfigReset(args[3]);
}
else {
ui.error('Usage: beat agents config <set|show|reset>');
process.exit(1);
}
}
else {
ui.error(`Unknown agents subcommand: ${subCommand}`);
process.stderr.write('Valid subcommands: list, check, config\n');
process.exit(1);
}
}
else if (mainCommand === 'resume') {

@@ -236,2 +285,5 @@ const taskId = args[1];

}
else if (mainCommand === 'init') {
await initCommand(args.slice(1));
}
else if (mainCommand === 'config') {

@@ -238,0 +290,0 @@ if (subCommand === 'show') {

@@ -17,2 +17,7 @@ import { readFileSync } from 'fs';

${bold('Setup:')}
${cyan('init')} Interactive first-time setup (select default agent)
-a, --agent AGENT Non-interactive: set default agent directly
-y, --yes Overwrite existing config without prompting
${bold('MCP Server Commands:')}

@@ -28,2 +33,3 @@ ${cyan('mcp start')} Start the MCP server

-w, --working-directory D Working directory for task execution
-a, --agent AGENT AI agent to use (claude, codex, gemini)
--deps TASK_IDS Comma-separated task IDs this task depends on (alias: --depends-on)

@@ -62,2 +68,9 @@ -c, --continue TASK_ID Continue from a dependency's checkpoint (alias: --continue-from)

${bold('Agent Commands:')}
${cyan('agents list')} List available AI agents
${cyan('agents check')} Check agent auth status and readiness
${cyan('agents config set')} <agent> apiKey <key> Store an API key for an agent
${cyan('agents config show')} <agent> Show stored config for an agent
${cyan('agents config reset')} <agent> Remove stored config for an agent
${bold('Pipeline Commands:')}

@@ -76,6 +89,10 @@ ${cyan('pipeline')} <prompt> [<prompt>]... Create chained one-time schedules

${bold('Examples:')}
beat init # Interactive setup
beat init --agent claude # Non-interactive (CI/scripting)
beat mcp start # Start MCP server
beat run "analyze this codebase" # Fire-and-forget (default)
beat run "fix the bug" --foreground # Stream output, wait
beat run "analyze code" --agent codex # Use Codex instead of Claude
beat run "run tests" --deps task-abc123 # Wait for dependency
beat agents list # List available agents
beat list # List all tasks

@@ -82,0 +99,0 @@

@@ -0,9 +1,36 @@

import { AGENT_PROVIDERS, isAgentProvider } from '../../core/agents.js';
import { withServices } from '../services.js';
import * as ui from '../ui.js';
export async function handlePipelineCommand(pipelineArgs) {
// Parse --agent flag before filtering positional args
let agent;
const filteredArgs = [];
for (let i = 0; i < pipelineArgs.length; i++) {
const arg = pipelineArgs[i];
const next = pipelineArgs[i + 1];
if (arg === '--agent' || arg === '-a') {
if (!next || next.startsWith('-')) {
ui.error(`--agent requires an agent name (${AGENT_PROVIDERS.join(', ')})`);
process.exit(1);
}
if (!isAgentProvider(next)) {
ui.error(`Unknown agent: "${next}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
agent = next;
i++;
}
else if (arg.startsWith('-')) {
ui.error(`Unknown flag: ${arg}`);
process.exit(1);
}
else {
filteredArgs.push(arg);
}
}
// Each positional arg is a pipeline step prompt
const steps = pipelineArgs.filter((arg) => !arg.startsWith('-'));
const steps = filteredArgs;
if (steps.length < 2) {
ui.error('Pipeline requires at least 2 steps');
process.stderr.write('Usage: beat pipeline <prompt> <prompt> [<prompt>]...\n');
process.stderr.write('Usage: beat pipeline <prompt> <prompt> [<prompt>]... [--agent AGENT]\n');
process.stderr.write('Example: beat pipeline "setup db" "run migrations" "seed data"\n');

@@ -16,3 +43,3 @@ process.exit(1);

const result = await scheduleService.createPipeline({
steps: steps.map((prompt) => ({ prompt })),
steps: steps.map((prompt) => ({ prompt, agent })),
});

@@ -26,2 +53,3 @@ if (!result.ok) {

// Show pipeline visualization
const pipelineTitle = agent ? `Pipeline Steps (agent: ${agent})` : 'Pipeline Steps';
const lines = [];

@@ -35,4 +63,4 @@ for (let i = 0; i < result.value.steps.length; i++) {

}
ui.note(lines.join('\n'), 'Pipeline Steps');
ui.note(lines.join('\n'), pipelineTitle);
process.exit(0);
}

@@ -22,2 +22,3 @@ import type { Container } from '../../core/container.js';

maxOutputBuffer?: number;
agent?: string;
}): Promise<void>;

@@ -214,2 +214,4 @@ import { spawn } from 'child_process';

params.push(`Dir: ${options.workingDirectory}`);
if (options.agent)
params.push(`Agent: ${options.agent}`);
if (options.dependsOn && options.dependsOn.length > 0)

@@ -232,2 +234,3 @@ params.push(`Deps: ${options.dependsOn.join(', ')}`);

continueFrom: options?.continueFrom ? TaskId(options.continueFrom) : undefined,
agent: options?.agent,
};

@@ -234,0 +237,0 @@ const result = await taskManager.delegate(request);

@@ -0,1 +1,2 @@

import { AGENT_PROVIDERS, isAgentProvider } from '../../core/agents.js';
import { ScheduleId } from '../../core/domain.js';

@@ -52,2 +53,3 @@ import { validatePath } from '../../utils/validation.js';

let afterScheduleId;
let agent;
for (let i = 0; i < scheduleArgs.length; i++) {

@@ -117,2 +119,14 @@ const arg = scheduleArgs[i];

}
else if (arg === '--agent' || arg === '-a') {
if (!next || next.startsWith('-')) {
ui.error(`--agent requires an agent name (${AGENT_PROVIDERS.join(', ')})`);
process.exit(1);
}
if (!isAgentProvider(next)) {
ui.error(`Unknown agent: "${next}". Available agents: ${AGENT_PROVIDERS.join(', ')}`);
process.exit(1);
}
agent = next;
i++;
}
else if (arg.startsWith('-')) {

@@ -165,2 +179,3 @@ ui.error(`Unknown flag: ${arg}`);

afterScheduleId: afterScheduleId ? ScheduleId(afterScheduleId) : undefined,
agent,
});

@@ -176,2 +191,4 @@ if (result.ok) {

details.push(`After: ${result.value.afterScheduleId}`);
if (agent)
details.push(`Agent: ${agent}`);
ui.info(details.join(' | '));

@@ -257,2 +274,4 @@ process.exit(0);

lines.push(`Prompt: ${schedule.taskTemplate.prompt.substring(0, 100)}${schedule.taskTemplate.prompt.length > 100 ? '...' : ''}`);
if (schedule.taskTemplate.agent)
lines.push(`Agent: ${schedule.taskTemplate.agent}`);
ui.note(lines.join('\n'), 'Schedule Details');

@@ -259,0 +278,0 @@ if (history && history.length > 0) {

@@ -18,2 +18,3 @@ import { TaskId } from '../../core/domain.js';

lines.push(`Priority: ${task.priority}`);
lines.push(`Agent: ${task.agent ?? 'unknown'}`);
if (task.startedAt)

@@ -20,0 +21,0 @@ lines.push(`Started: ${new Date(task.startedAt).toISOString()}`);

@@ -18,2 +18,5 @@ /**

export declare function step(msg: string): void;
export declare function intro(msg: string): void;
export declare function outro(msg: string): void;
export declare function cancel(msg: string): void;
export declare function note(msg: string, title?: string): void;

@@ -28,1 +31,2 @@ export declare function colorStatus(status: string): string;

export declare function cyan(text: string): string;
export declare function yellow(text: string): string;

@@ -80,2 +80,27 @@ /**

}
// Session markers (intro/outro/cancel for interactive flows)
export function intro(msg) {
if (isTTY) {
p.intro(msg, { output });
}
else {
output.write(`${msg}\n`);
}
}
export function outro(msg) {
if (isTTY) {
p.outro(msg, { output });
}
else {
output.write(`${msg}\n`);
}
}
export function cancel(msg) {
if (isTTY) {
p.cancel(msg, { output });
}
else {
output.write(`${msg}\n`);
}
}
// Boxed display (task details, config sections)

@@ -158,1 +183,4 @@ export function note(msg, title) {

}
export function yellow(text) {
return isTTY ? pc.yellow(text) : text;
}
import { z } from 'zod';
import { type AgentProvider } from './agents.js';
/**

@@ -31,2 +32,3 @@ * Configuration Schema with Zod

taskRetentionDays: z.ZodDefault<z.ZodNumber>;
defaultAgent: z.ZodOptional<z.ZodEnum<[AgentProvider, ...AgentProvider[]]>>;
}, "strip", z.ZodTypeAny, {

@@ -37,3 +39,3 @@ timeout: number;

memoryReserve: number;
logLevel: "debug" | "info" | "warn" | "error";
logLevel: "error" | "debug" | "info" | "warn";
maxListenersPerEvent: number;

@@ -51,2 +53,3 @@ maxTotalSubscriptions: number;

taskRetentionDays: number;
defaultAgent?: AgentProvider | undefined;
}, {

@@ -57,3 +60,3 @@ timeout?: number | undefined;

memoryReserve?: number | undefined;
logLevel?: "debug" | "info" | "warn" | "error" | undefined;
logLevel?: "error" | "debug" | "info" | "warn" | undefined;
maxListenersPerEvent?: number | undefined;

@@ -71,2 +74,3 @@ maxTotalSubscriptions?: number | undefined;

taskRetentionDays?: number | undefined;
defaultAgent?: AgentProvider | undefined;
}>;

@@ -79,7 +83,3 @@ export type Configuration = z.infer<typeof ConfigurationSchema>;

export declare function loadConfiguration(): Configuration;
export declare const CONFIG_FILE_PATH: string;
/** Test helper: redirect config reads/writes to a temp directory. Returns restore function. */
export declare function _testSetConfigDir(dir: string): () => void;
export declare function loadConfigFile(): Record<string, unknown>;
export declare function saveConfigValue(key: string, value: unknown): {
type ConfigWriteResult = {
ok: true;

@@ -90,7 +90,23 @@ } | {

};
export declare function resetConfigValue(key: string): {
ok: true;
} | {
ok: false;
error: string;
};
export declare const CONFIG_FILE_PATH: string;
/** Test helper: redirect config reads/writes to a temp directory. Returns restore function. */
export declare function _testSetConfigDir(dir: string): () => void;
export declare function loadConfigFile(): Record<string, unknown>;
export declare function saveConfigValue(key: string, value: unknown): ConfigWriteResult;
export declare function resetConfigValue(key: string): ConfigWriteResult;
export interface AgentConfig {
readonly apiKey?: string;
}
/**
* Load agent-specific config from the `agents.<provider>` section of config.json
*/
export declare function loadAgentConfig(provider: AgentProvider): AgentConfig;
/**
* Save a key-value pair under the `agents.<provider>` section of config.json
*/
export declare function saveAgentConfig(provider: AgentProvider, key: 'apiKey', value: string): ConfigWriteResult;
/**
* Remove all stored config for a specific agent provider
*/
export declare function resetAgentConfig(provider: AgentProvider): ConfigWriteResult;
export {};

@@ -1,5 +0,6 @@

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import path from 'path';
import { z } from 'zod';
import { AGENT_PROVIDERS_TUPLE, isAgentProvider } from './agents.js';
/**

@@ -49,2 +50,4 @@ * Configuration Schema with Zod

taskRetentionDays: z.number().min(1).max(365).default(7), // Default: keep tasks for 7 days
// Agent configuration (v0.5.0 Multi-Agent Support)
defaultAgent: z.enum(AGENT_PROVIDERS_TUPLE).optional(),
});

@@ -130,2 +133,4 @@ const DEFAULT_CONFIG = {

envConfig.taskRetentionDays = parseEnvNumber(process.env.TASK_RETENTION_DAYS, 0);
if (process.env.BACKBEAT_DEFAULT_AGENT && isAgentProvider(process.env.BACKBEAT_DEFAULT_AGENT))
envConfig.defaultAgent = process.env.BACKBEAT_DEFAULT_AGENT;
// Layer 2: Config file values (lower priority than env vars)

@@ -151,5 +156,2 @@ const fileConfig = loadConfigFile();

}
// ============================================================================
// Config File Persistence (~/.backbeat/config.json)
// ============================================================================
// Display path for CLI (always shows real home path)

@@ -171,2 +173,14 @@ export const CONFIG_FILE_PATH = path.join(homedir(), '.backbeat', 'config.json');

}
/** Write config object to disk with secure permissions (dir 0o700, file 0o600) */
function writeConfigFile(data) {
try {
mkdirSync(_configDir, { recursive: true, mode: 0o700 });
writeFileSync(_configFilePath, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
chmodSync(_configFilePath, 0o600); // Ensure permissions on pre-existing files (writeFileSync mode only applies on creation)
return { ok: true };
}
catch {
return { ok: false, error: `Failed to write config file at ${_configFilePath}` };
}
}
export function loadConfigFile() {

@@ -204,10 +218,3 @@ try {

existing[key] = fieldResult.data;
try {
mkdirSync(_configDir, { recursive: true });
writeFileSync(_configFilePath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
return { ok: true };
}
catch {
return { ok: false, error: `Failed to write config file at ${_configFilePath}` };
}
return writeConfigFile(existing);
}

@@ -225,10 +232,54 @@ export function resetConfigValue(key) {

delete existing[key];
try {
mkdirSync(_configDir, { recursive: true });
writeFileSync(_configFilePath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
return { ok: true };
return writeConfigFile(existing);
}
/**
* Load agent-specific config from the `agents.<provider>` section of config.json
*/
export function loadAgentConfig(provider) {
const file = loadConfigFile();
const agents = file.agents;
if (!agents || typeof agents !== 'object' || Array.isArray(agents))
return {};
const section = agents[provider];
if (!section || typeof section !== 'object' || Array.isArray(section))
return {};
const record = section;
return {
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
/**
* Save a key-value pair under the `agents.<provider>` section of config.json
*/
export function saveAgentConfig(provider, key, value) {
const existing = loadConfigFile();
const agents = (existing.agents && typeof existing.agents === 'object' && !Array.isArray(existing.agents) ? existing.agents : {});
const section = (agents[provider] && typeof agents[provider] === 'object' && !Array.isArray(agents[provider]) ? agents[provider] : {});
section[key] = value;
agents[provider] = section;
existing.agents = agents;
return writeConfigFile(existing);
}
/**
* Remove all stored config for a specific agent provider
*/
export function resetAgentConfig(provider) {
const existing = loadConfigFile();
const agents = existing.agents;
if (!agents || typeof agents !== 'object' || Array.isArray(agents)) {
return { ok: true }; // Nothing to reset
}
catch {
return { ok: false, error: `Failed to write config file at ${_configFilePath}` };
const agentsRecord = agents;
if (!(provider in agentsRecord)) {
return { ok: true }; // Already clean
}
delete agentsRecord[provider];
// Clean up empty agents object
if (Object.keys(agentsRecord).length === 0) {
delete existing.agents;
}
else {
existing.agents = agentsRecord;
}
return writeConfigFile(existing);
}

@@ -5,2 +5,3 @@ /**

*/
import { AgentProvider } from './agents.js';
import { BackbeatError } from './errors.js';

@@ -75,2 +76,3 @@ export type TaskId = string & {

readonly continueFrom?: TaskId;
readonly agent?: AgentProvider;
readonly createdAt: number;

@@ -117,2 +119,3 @@ readonly updatedAt?: number;

readonly continueFrom?: TaskId;
readonly agent?: AgentProvider;
}

@@ -235,2 +238,3 @@ export interface TaskUpdate {

readonly afterScheduleId?: ScheduleId;
readonly agent?: AgentProvider;
}

@@ -245,2 +249,3 @@ /**

readonly workingDirectory?: string;
readonly agent?: AgentProvider;
}

@@ -251,2 +256,3 @@ export interface PipelineCreateRequest {

readonly workingDirectory?: string;
readonly agent?: AgentProvider;
}

@@ -253,0 +259,0 @@ export interface PipelineStep {

@@ -87,2 +87,4 @@ /**

maxOutputBuffer: request.maxOutputBuffer,
// Multi-agent support (v0.5.0)
agent: request.agent,
createdAt: now,

@@ -89,0 +91,0 @@ updatedAt: now,

@@ -74,3 +74,7 @@ /**

/** Attempted operation on empty queue */
QUEUE_EMPTY = "QUEUE_EMPTY"
QUEUE_EMPTY = "QUEUE_EMPTY",
/** Requested agent provider is not registered in the registry */
AGENT_NOT_FOUND = "AGENT_NOT_FOUND",
/** Agent adapter exists but is misconfigured (e.g., CLI not installed) */
AGENT_MISCONFIGURED = "AGENT_MISCONFIGURED"
}

@@ -111,2 +115,4 @@ /**

export declare const resourceLimitExceeded: (resourceType: string, limit: number, current: number) => BackbeatError;
export declare const agentNotFound: (provider: string, available: readonly string[]) => BackbeatError;
export declare const agentMisconfigured: (provider: string, reason: string) => BackbeatError;
/**

@@ -113,0 +119,0 @@ * Type guard for BackbeatError

@@ -83,2 +83,7 @@ /**

ErrorCode["QUEUE_EMPTY"] = "QUEUE_EMPTY";
// Agent errors (v0.5.0 Multi-Agent Support)
/** Requested agent provider is not registered in the registry */
ErrorCode["AGENT_NOT_FOUND"] = "AGENT_NOT_FOUND";
/** Agent adapter exists but is misconfigured (e.g., CLI not installed) */
ErrorCode["AGENT_MISCONFIGURED"] = "AGENT_MISCONFIGURED";
})(ErrorCode || (ErrorCode = {}));

@@ -126,2 +131,7 @@ /**

export const resourceLimitExceeded = (resourceType, limit, current) => new BackbeatError(ErrorCode.RESOURCE_LIMIT_EXCEEDED, `Resource limit exceeded for ${resourceType}: limit=${limit}, current=${current}`, { resourceType, limit, current });
export const agentNotFound = (provider, available) => new BackbeatError(ErrorCode.AGENT_NOT_FOUND, `Agent '${provider}' not found. Available agents: ${available.join(', ')}`, { provider, available });
export const agentMisconfigured = (provider, reason) => new BackbeatError(ErrorCode.AGENT_MISCONFIGURED, `Agent '${provider}' is misconfigured: ${reason}`, {
provider,
reason,
});
/**

@@ -128,0 +138,0 @@ * Type guard for BackbeatError

@@ -470,2 +470,9 @@ /**

},
{
version: 7,
description: 'Add agent column for multi-agent support (v0.5.0)',
up: (db) => {
db.exec(`ALTER TABLE tasks ADD COLUMN agent TEXT DEFAULT 'claude'`);
},
},
];

@@ -472,0 +479,0 @@ }

/**
* Event-driven worker pool implementation
* Eliminates race conditions through event-based coordination
*
* ARCHITECTURE (v0.5.0): Uses AgentRegistry to resolve the correct agent adapter
* per task. Requires task.agent to be set (resolved by TaskManager before queueing).
*/
import { AgentRegistry } from '../core/agents.js';
import { Task, TaskId, Worker, WorkerId } from '../core/domain.js';
import { EventBus } from '../core/events/event-bus.js';
import { Logger, OutputCapture, ProcessSpawner, ResourceMonitor, WorkerPool } from '../core/interfaces.js';
import { Logger, OutputCapture, ResourceMonitor, WorkerPool } from '../core/interfaces.js';
import { Result } from '../core/result.js';
export declare class EventDrivenWorkerPool implements WorkerPool {
private readonly spawner;
private readonly agentRegistry;
private readonly monitor;

@@ -17,3 +21,3 @@ private readonly logger;

private readonly processConnector;
constructor(spawner: ProcessSpawner, monitor: ResourceMonitor, logger: Logger, eventBus: EventBus, outputCapture: OutputCapture);
constructor(agentRegistry: AgentRegistry, monitor: ResourceMonitor, logger: Logger, eventBus: EventBus, outputCapture: OutputCapture);
spawn(task: Task): Promise<Result<Worker>>;

@@ -42,6 +46,2 @@ kill(workerId: WorkerId): Promise<Result<void>>;

private handleWorkerTimeout;
/**
* Handle worker process errors
*/
private handleWorkerError;
}
/**
* Event-driven worker pool implementation
* Eliminates race conditions through event-based coordination
*
* ARCHITECTURE (v0.5.0): Uses AgentRegistry to resolve the correct agent adapter
* per task. Requires task.agent to be set (resolved by TaskManager before queueing).
*/

@@ -10,3 +13,3 @@ import { WorkerId } from '../core/domain.js';

export class EventDrivenWorkerPool {
spawner;
agentRegistry;
monitor;

@@ -18,4 +21,4 @@ logger;

processConnector;
constructor(spawner, monitor, logger, eventBus, outputCapture) {
this.spawner = spawner;
constructor(agentRegistry, monitor, logger, eventBus, outputCapture) {
this.agentRegistry = agentRegistry;
this.monitor = monitor;

@@ -30,3 +33,9 @@ this.logger = logger;

prompt: task.prompt.substring(0, 100),
agent: task.agent ?? 'unknown',
});
// Guard: task.agent must be set by TaskManager before reaching worker pool
const agentProvider = task.agent;
if (!agentProvider) {
return err(new BackbeatError(ErrorCode.WORKER_SPAWN_FAILED, 'Task has no agent assigned. This may be a task from before v0.5.0. Re-delegate with --agent.'));
}
// Check if we can spawn based on resources

@@ -40,7 +49,13 @@ const canSpawnResult = await this.monitor.canSpawnWorker();

}
// Resolve the agent adapter for this task
const adapterResult = this.agentRegistry.get(agentProvider);
if (!adapterResult.ok) {
return err(adapterResult.error);
}
const adapter = adapterResult.value;
const finalWorkingDirectory = task.workingDirectory || process.cwd();
// Spawn the process with task ID for identification
const spawnResult = this.spawner.spawn(task.prompt, finalWorkingDirectory, task.id);
// Spawn the process using the resolved adapter
const spawnResult = adapter.spawn(task.prompt, finalWorkingDirectory, task.id);
if (!spawnResult.ok) {
return err(new BackbeatError(ErrorCode.WORKER_SPAWN_FAILED, `Failed to spawn worker: ${spawnResult.error.message}`));
return err(spawnResult.error);
}

@@ -73,2 +88,3 @@ const { process: childProcess, pid } = spawnResult.value;

pid: worker.pid,
agent: agentProvider,
});

@@ -255,14 +271,2 @@ // Note: WorkerSpawned event is emitted by WorkerHandler, not here

}
/**
* Handle worker process errors
*/
async handleWorkerError(taskId, error) {
this.logger.error('Worker process error', error, { taskId });
// Emit task failed event
await this.eventBus.emit('TaskFailed', {
taskId,
exitCode: 1,
error: new BackbeatError(ErrorCode.TASK_EXECUTION_FAILED, `Worker process error: ${error.message}`),
});
}
}

@@ -8,2 +8,3 @@ /**

import { z } from 'zod';
import { AGENT_PROVIDERS_TUPLE } from '../core/agents.js';
import { MissedRunPolicy, ScheduleId, ScheduleStatus, ScheduleType, TaskId, } from '../core/domain.js';

@@ -63,2 +64,3 @@ import { BackbeatError, ErrorCode, operationErrorHandler } from '../core/errors.js';

continueFrom: z.string().optional(),
agent: z.enum(AGENT_PROVIDERS_TUPLE).optional(),
});

@@ -65,0 +67,0 @@ export class SQLiteScheduleRepository {

@@ -6,2 +6,3 @@ /**

import { z } from 'zod';
import { AGENT_PROVIDERS_TUPLE } from '../core/agents.js';
import { BackbeatError, ErrorCode, operationErrorHandler } from '../core/errors.js';

@@ -31,2 +32,3 @@ import { err, tryCatchAsync } from '../core/result.js';

continue_from: z.string().nullable(),
agent: z.enum(AGENT_PROVIDERS_TUPLE).nullable(),
});

@@ -54,3 +56,3 @@ export class SQLiteTaskRepository {

created_at, started_at, completed_at, worker_id, exit_code, dependencies,
parent_task_id, retry_count, retry_of, continue_from
parent_task_id, retry_count, retry_of, continue_from, agent
) VALUES (

@@ -60,3 +62,3 @@ @id, @prompt, @status, @priority, @workingDirectory,

@createdAt, @startedAt, @completedAt, @workerId, @exitCode, @dependencies,
@parentTaskId, @retryCount, @retryOf, @continueFrom
@parentTaskId, @retryCount, @retryOf, @continueFrom, @agent
)

@@ -82,3 +84,4 @@ `);

retry_of = @retryOf,
continue_from = @continueFrom
continue_from = @continueFrom,
agent = @agent
WHERE id = @id

@@ -90,3 +93,3 @@ `);

created_at, started_at, completed_at, worker_id, exit_code,
dependencies, continue_from
dependencies, continue_from, agent
FROM tasks WHERE id = ?

@@ -98,3 +101,3 @@ `);

created_at, started_at, completed_at, worker_id, exit_code,
dependencies, continue_from
dependencies, continue_from, agent
FROM tasks ORDER BY created_at DESC

@@ -106,3 +109,3 @@ `);

created_at, started_at, completed_at, worker_id, exit_code,
dependencies, continue_from
dependencies, continue_from, agent
FROM tasks WHERE status = ? ORDER BY created_at DESC

@@ -117,3 +120,3 @@ `);

created_at, started_at, completed_at, worker_id, exit_code,
dependencies, continue_from
dependencies, continue_from, agent
FROM tasks ORDER BY created_at DESC LIMIT ? OFFSET ?

@@ -151,2 +154,3 @@ `);

continueFrom: task.continueFrom || null,
agent: task.agent || null,
};

@@ -186,2 +190,3 @@ this.saveStmt.run(dbTask);

continueFrom: updatedTask.continueFrom || null,
agent: updatedTask.agent || null,
});

@@ -271,2 +276,3 @@ }, operationErrorHandler('update task', { taskId }));

continueFrom: data.continue_from ? data.continue_from : undefined,
agent: data.agent ?? undefined,
createdAt: data.created_at,

@@ -273,0 +279,0 @@ startedAt: data.started_at || undefined,

@@ -7,2 +7,3 @@ /**

*/
import { Configuration } from '../core/configuration.js';
import { MissedRunPolicy, PipelineCreateRequest, PipelineResult, Schedule, ScheduleCreateRequest, ScheduleId, ScheduleStatus } from '../core/domain.js';

@@ -21,3 +22,4 @@ import { EventBus } from '../core/events/event-bus.js';

private readonly scheduleRepository;
constructor(eventBus: EventBus, logger: Logger, scheduleRepository: ScheduleRepository);
private readonly config;
constructor(eventBus: EventBus, logger: Logger, scheduleRepository: ScheduleRepository, config: Configuration);
createSchedule(request: ScheduleCreateRequest): Promise<Result<Schedule>>;

@@ -24,0 +26,0 @@ listSchedules(status?: ScheduleStatus, limit?: number, offset?: number): Promise<Result<readonly Schedule[]>>;

@@ -7,2 +7,3 @@ /**

*/
import { resolveDefaultAgent } from '../core/agents.js';
import { createSchedule, MissedRunPolicy, ScheduleStatus, ScheduleType, } from '../core/domain.js';

@@ -38,6 +39,8 @@ import { BackbeatError, ErrorCode } from '../core/errors.js';

scheduleRepository;
constructor(eventBus, logger, scheduleRepository) {
config;
constructor(eventBus, logger, scheduleRepository, config) {
this.eventBus = eventBus;
this.logger = logger;
this.scheduleRepository = scheduleRepository;
this.config = config;
this.logger.debug('ScheduleManagerService initialized');

@@ -122,2 +125,6 @@ }

}
// Resolve agent (same pattern as TaskManager.delegate)
const agentResult = resolveDefaultAgent(request.agent, this.config.defaultAgent);
if (!agentResult.ok)
return agentResult;
// Create schedule via domain factory

@@ -129,2 +136,3 @@ const schedule = createSchedule({

workingDirectory: validatedWorkingDirectory,
agent: agentResult.value,
},

@@ -256,2 +264,3 @@ scheduleType: request.scheduleType,

afterScheduleId: previousScheduleId,
agent: step.agent ?? request.agent,
});

@@ -258,0 +267,0 @@ if (!result.ok) {

@@ -15,2 +15,3 @@ /**

*/
import { resolveDefaultAgent } from '../core/agents.js';
import { createTask, isTerminalState, TaskId, } from '../core/domain.js';

@@ -59,2 +60,7 @@ import { BackbeatError, ErrorCode, taskNotFound } from '../core/errors.js';

}
// Resolve agent: explicit → config default → error
const agentResult = resolveDefaultAgent(requestWithDefaults.agent, this.config.defaultAgent);
if (!agentResult.ok)
return agentResult;
requestWithDefaults = { ...requestWithDefaults, agent: agentResult.value };
// Create task using pure function with defaults applied

@@ -66,2 +72,3 @@ const task = createTask(requestWithDefaults);

prompt: task.prompt.substring(0, 100),
agent: task.agent,
});

@@ -180,2 +187,3 @@ // Emit event - all state management happens in event handlers

retryOf: taskId,
agent: originalTask.agent,
};

@@ -265,2 +273,3 @@ // Create the new retry task

retryOf: taskId,
agent: originalTask.agent,
};

@@ -267,0 +276,0 @@ const newTask = createTask(resumeRequest);

@@ -48,6 +48,5 @@ /**

dirtyFiles = statusResult.stdout
.trim()
.split('\n')
.map((line) => line.substring(3).trim()) // Remove status prefix (e.g., " M ", "?? ")
.filter((file) => file.length > 0);
.filter((line) => line.length > 0)
.map((line) => line.substring(3).trim()); // Remove status prefix (e.g., " M ", "?? ")
}

@@ -54,0 +53,0 @@ }

{
"name": "backbeat",
"version": "0.4.1",
"version": "0.5.0",
"main": "dist/index.js",

@@ -20,14 +20,14 @@ "bin": {

"test:all": "npm run test:core && npm run test:handlers && npm run test:services && npm run test:repositories && npm run test:adapters && npm run test:implementations && npm run test:cli && npm run test:scheduling && npm run test:checkpoints && npm run test:error-scenarios && npm run test:integration",
"test:services": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/task-manager.test.ts tests/unit/services/recovery-manager.test.ts tests/unit/services/autoscaling-manager.test.ts --no-file-parallelism",
"test:services": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/task-manager.test.ts tests/unit/services/recovery-manager.test.ts tests/unit/services/autoscaling-manager.test.ts tests/unit/services/process-connector.test.ts --no-file-parallelism",
"test:full": "npm run test:all && npm run test:worker-handler",
"test:unit": "NODE_OPTIONS='--max-old-space-size=2048' vitest run --no-file-parallelism",
"test:core": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/core --no-file-parallelism",
"test:handlers": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/handlers/dependency-handler.test.ts tests/unit/services/handlers/query-handler.test.ts tests/unit/services/handlers/schedule-handler.test.ts tests/unit/services/handlers/checkpoint-handler.test.ts --no-file-parallelism",
"test:handlers": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/handlers/dependency-handler.test.ts tests/unit/services/handlers/query-handler.test.ts tests/unit/services/handlers/schedule-handler.test.ts tests/unit/services/handlers/checkpoint-handler.test.ts tests/unit/services/handlers/persistence-handler.test.ts tests/unit/services/handlers/queue-handler.test.ts tests/unit/services/handlers/output-handler.test.ts --no-file-parallelism",
"test:worker-handler": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/handlers/worker-handler.test.ts --no-file-parallelism --testTimeout=60000",
"test:repositories": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/implementations/dependency-repository.test.ts tests/unit/implementations/task-repository.test.ts tests/unit/implementations/database.test.ts tests/unit/implementations/checkpoint-repository.test.ts --no-file-parallelism",
"test:repositories": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/implementations/dependency-repository.test.ts tests/unit/implementations/task-repository.test.ts tests/unit/implementations/database.test.ts tests/unit/implementations/checkpoint-repository.test.ts tests/unit/implementations/output-repository.test.ts --no-file-parallelism",
"test:adapters": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/adapters --no-file-parallelism",
"test:implementations": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/implementations --exclude='**/dependency-repository.test.ts' --exclude='**/task-repository.test.ts' --exclude='**/database.test.ts' --no-file-parallelism",
"test:implementations": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/implementations --exclude='**/dependency-repository.test.ts' --exclude='**/task-repository.test.ts' --exclude='**/database.test.ts' --exclude='**/checkpoint-repository.test.ts' --exclude='**/output-repository.test.ts' --no-file-parallelism",
"test:scheduling": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/services/schedule-manager.test.ts tests/unit/services/schedule-executor.test.ts tests/unit/services/handlers/schedule-handler.test.ts --no-file-parallelism",
"test:checkpoints": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/implementations/checkpoint-repository.test.ts tests/unit/services/handlers/checkpoint-handler.test.ts --no-file-parallelism",
"test:cli": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/cli.test.ts tests/unit/retry-functionality.test.ts --no-file-parallelism",
"test:cli": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/cli.test.ts tests/unit/cli-init.test.ts tests/unit/retry-functionality.test.ts --no-file-parallelism",
"test:error-scenarios": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/unit/error-scenarios --no-file-parallelism",

@@ -34,0 +34,0 @@ "test:integration": "NODE_OPTIONS='--max-old-space-size=2048' vitest run tests/integration --no-file-parallelism",

@@ -318,3 +318,4 @@ # Backbeat - Task Delegation And Management Framework

- [x] v0.4.0 - Task scheduling and task resumption
- [ ] v0.5.0 - CLI usability improvements and detach-by-default
- [x] v0.5.0 - Multi-agent support (Claude, Codex, Gemini)
- [ ] v0.6.0 - Scheduled pipelines and loops

@@ -321,0 +322,0 @@ See **[ROADMAP.md](./docs/ROADMAP.md)** for detailed plans and timelines.