picogent/src/config.ts
Markov cb618a195e feat: persistent session UUID in agent.json
- session_id auto-generated on first run, saved to agent.json
- Survives agent renames (slug changes don't break session history)
- Directory mode: agent works inside its folder (agentHome = workspace)
2026-02-24 10:45:48 +01:00

192 lines
5.9 KiB
TypeScript

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<string, unknown> = {};
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<string, unknown>, 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;
}