| /** | ||
| * 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(); | ||
| } | ||
| } | ||
| } |
@@ -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 |
+52
-0
@@ -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()}`); |
+4
-0
@@ -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; |
+28
-0
@@ -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 |
+10
-0
@@ -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 @@ } |
+6
-6
| { | ||
| "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", |
+2
-1
@@ -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. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
787656
9.11%143
14.4%19019
9.35%361
0.28%63
14.55%13
62.5%