import path from 'path'; import os from 'os'; import fs from 'fs'; import crypto from 'crypto'; export interface Config { port: number; defaultWorkDir: string; sessionDir: string; model: string; logLevel: string; apiKey: string; provider: string; } export interface AgentConfig { name: string; slug: string; prompt: string; /** REST API base URL (e.g. https://dev.team.uix.su/agent-api) */ trackerUrl: string; /** WebSocket URL (e.g. wss://dev.team.uix.su/agent-ws). Falls back to trackerUrl with http→ws conversion. */ wsUrl: string; token: string; transport: 'http' | 'ws'; listenPort: number; workDir: string; agentHome: string; capabilities: string[]; maxConcurrentTasks: number; model: string; provider: string; apiKey: string; heartbeatIntervalSec: number; /** Restrict file access to these directories. Empty = unrestricted. */ allowedPaths: string[]; /** Persistent session UUID — survives renames. Stored in agent.json. */ sessionId: string; } const homeDir = os.homedir(); export function loadConfig(): Config { return { port: parseInt(process.env.PICOGENT_PORT || '3100', 10), defaultWorkDir: process.env.PICOGENT_WORK_DIR || process.cwd(), sessionDir: process.env.PICOGENT_SESSION_DIR || path.join(homeDir, '.picogent', 'sessions'), model: process.env.PICOGENT_MODEL || 'sonnet', logLevel: process.env.LOG_LEVEL || 'info', apiKey: process.env.PICOGENT_API_KEY || process.env.ANTHROPIC_API_KEY || '', provider: process.env.PICOGENT_PROVIDER || 'anthropic', }; } interface ResolvedConfig { configPath: string | null; agentHome: string | null; } /** * Resolve config: CLI arg can be a .json file OR a directory. * * Directory mode: picogent ./agents/coder/ * → configPath = ./agents/coder/agent.json * → agentHome = ./agents/coder/ * * File mode: picogent ./agent.json * → configPath = ./agent.json * → agentHome = null * * Env/default fallback: same as before */ export function resolveConfig(): ResolvedConfig { const arg = process.argv[2]; if (arg) { const resolved = path.resolve(arg); // Directory mode: picogent ./agents/coder/ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { const configInDir = path.join(resolved, 'agent.json'); return { configPath: fs.existsSync(configInDir) ? configInDir : null, agentHome: resolved, }; } // File mode: picogent ./agent.json if (arg.endsWith('.json')) { return { configPath: resolved, agentHome: null }; } } // Env fallback if (process.env.PICOGENT_CONFIG) { return { configPath: path.resolve(process.env.PICOGENT_CONFIG), agentHome: null }; } // Default: ./agent.json if exists const defaultPath = path.resolve('./agent.json'); if (fs.existsSync(defaultPath)) { return { configPath: defaultPath, agentHome: null }; } return { configPath: null, agentHome: null }; } /** * Agent mode if: config found, directory passed, or TRACKER_URL env set. */ export function hasAgentConfig(): boolean { const { configPath, agentHome } = resolveConfig(); return configPath !== null || agentHome !== null || !!process.env.TRACKER_URL; } export function loadAgentConfig(): AgentConfig { const { configPath, agentHome } = resolveConfig(); // Load file if available, otherwise empty object let file: Record = {}; if (configPath) { if (!fs.existsSync(configPath)) { throw new Error(`Agent config not found: ${configPath}`); } file = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } const trackerUrl = process.env.TRACKER_URL || (file.tracker_url as string) || ''; const token = process.env.AGENT_TOKEN || (file.token as string) || ''; if (!trackerUrl) { throw new Error('Tracker URL required. Set TRACKER_URL env or tracker_url in config.'); } if (!token) { throw new Error('Agent token required. Set AGENT_TOKEN env or token in config.'); } // In directory mode, agentHome IS the work dir (unless overridden) const resolvedHome = agentHome || ''; const workDir = process.env.PICOGENT_WORK_DIR || (file.work_dir as string) || resolvedHome || process.cwd(); const wsUrl = process.env.AGENT_WS_URL || (file.ws_url as string) || ''; return { name: (file.name as string) || process.env.AGENT_NAME || 'Agent', slug: (file.slug as string) || process.env.AGENT_SLUG || 'agent', prompt: (file.prompt as string) || process.env.AGENT_PROMPT || '', trackerUrl, wsUrl, token, transport: (process.env.AGENT_TRANSPORT || (file.transport as string) || 'http') as 'http' | 'ws', listenPort: parseInt(process.env.AGENT_PORT || String(file.listen_port || '3200'), 10), workDir, agentHome: resolvedHome || workDir, capabilities: (file.capabilities as string[]) || ['coding'], maxConcurrentTasks: (file.max_concurrent_tasks as number) || 2, model: process.env.PICOGENT_MODEL || (file.model as string) || 'sonnet', provider: process.env.PICOGENT_PROVIDER || (file.provider as string) || 'anthropic', apiKey: process.env.PICOGENT_API_KEY || process.env.ANTHROPIC_API_KEY || (file.api_key as string) || '', heartbeatIntervalSec: (file.heartbeat_interval_sec as number) || 30, allowedPaths: (file.allowed_paths as string[]) || [], sessionId: ensureSessionId(file, configPath), }; } /** * Ensure agent.json has a persistent session_id. * Generates UUID on first run and writes it back to the config file. */ function ensureSessionId(file: Record, configPath: string | null): string { if (file.session_id && typeof file.session_id === 'string') { return file.session_id; } const id = crypto.randomUUID(); // Persist to agent.json if we have a path if (configPath) { try { file.session_id = id; fs.writeFileSync(configPath, JSON.stringify(file, null, 2) + '\n'); } catch { // Non-fatal — session works in memory } } return id; }