diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts new file mode 100644 index 0000000..ae1c65a --- /dev/null +++ b/src/agent-runner-environment.ts @@ -0,0 +1,414 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { GROUPS_DIR, TIMEZONE } from './config.js'; +import { isPairedRoomJid } from './db.js'; +import { readEnvFile } from './env.js'; +import { + resolveGroupFolderPath, + resolveGroupIpcPath, + resolveGroupSessionsPath, + resolveTaskRuntimeIpcPath, + resolveTaskSessionsPath, +} from './group-folder.js'; +import { + readPairedRoomPrompt, + readPlatformPrompt, +} from './platform-prompts.js'; +import type { AgentType, RegisteredGroup } from './types.js'; + +function syncDirectoryEntries(sources: string[], destination: string): void { + for (const source of sources) { + if (!fs.existsSync(source)) continue; + for (const entry of fs.readdirSync(source)) { + const srcPath = path.join(source, entry); + const dstPath = path.join(destination, entry); + if (fs.statSync(srcPath).isDirectory()) { + fs.cpSync(srcPath, dstPath, { recursive: true }); + } else { + fs.mkdirSync(destination, { recursive: true }); + fs.copyFileSync(srcPath, dstPath); + } + } + } +} + +function ensureClaudeSessionSettings(groupSessionsDir: string): void { + const settingsFile = path.join(groupSessionsDir, 'settings.json'); + if (fs.existsSync(settingsFile)) return; + + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); +} + +function buildBaseRunnerEnv(args: { + group: RegisteredGroup; + chatJid: string; + isMain: boolean; + groupDir: string; + groupIpcDir: string; + globalDir: string; + groupSessionsDir: string; + agentType: AgentType; + envVars: Record; +}): Record { + const cleanEnv = { ...(process.env as Record) }; + for (const [key, value] of Object.entries(args.envVars)) { + if (value && !cleanEnv[key]) cleanEnv[key] = value; + } + delete cleanEnv.CLAUDECODE; + delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; + + const nodeBin = path.dirname(process.execPath); + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin'); + const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin'; + const extraPaths = [nodeBin, npmGlobalBin].filter( + (candidate) => !currentPath.includes(candidate) && fs.existsSync(candidate), + ); + + return { + ...cleanEnv, + PATH: + extraPaths.length > 0 + ? `${extraPaths.join(':')}:${currentPath}` + : currentPath, + TZ: TIMEZONE, + HOME: os.homedir(), + NANOCLAW_GROUP_DIR: args.groupDir, + NANOCLAW_IPC_DIR: args.groupIpcDir, + NANOCLAW_GLOBAL_DIR: args.globalDir, + ...(args.group.workDir ? { NANOCLAW_WORK_DIR: args.group.workDir } : {}), + NANOCLAW_CHAT_JID: args.chatJid, + NANOCLAW_GROUP_FOLDER: args.group.folder, + NANOCLAW_IS_MAIN: args.isMain ? '1' : '0', + NANOCLAW_AGENT_TYPE: args.agentType, + CLAUDE_CONFIG_DIR: args.groupSessionsDir, + }; +} + +function prepareClaudeEnvironment(args: { + env: Record; + envVars: Record; + group: RegisteredGroup; +}): void { + if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { + args.env.ANTHROPIC_API_KEY = + args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; + } + if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) { + args.env.ANTHROPIC_AUTH_TOKEN = + args.envVars.ANTHROPIC_AUTH_TOKEN || + process.env.ANTHROPIC_AUTH_TOKEN || + ''; + } + if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) { + args.env.ANTHROPIC_BASE_URL = + args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || ''; + } + if ( + args.envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN + ) { + args.env.CLAUDE_CODE_OAUTH_TOKEN = + args.envVars.CLAUDE_CODE_OAUTH_TOKEN || + process.env.CLAUDE_CODE_OAUTH_TOKEN || + ''; + } + for (const key of [ + 'CLAUDE_MODEL', + 'CLAUDE_THINKING', + 'CLAUDE_THINKING_BUDGET', + 'CLAUDE_EFFORT', + ]) { + const value = + args.envVars[key as keyof typeof args.envVars] || process.env[key]; + if (value) args.env[key] = value; + } + if (args.group.agentConfig?.claudeModel) { + args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel; + } + if (args.group.agentConfig?.claudeEffort) { + args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort; + } +} + +function prepareCodexSessionEnvironment(args: { + env: Record; + envVars: Record; + projectRoot: string; + group: RegisteredGroup; + groupDir: string; + sessionRootDir: string; + chatJid: string; + isMain: boolean; + isPairedRoom: boolean; +}): void { + const openaiKey = + args.envVars.CODEX_OPENAI_API_KEY || + process.env.CODEX_OPENAI_API_KEY || + args.envVars.OPENAI_API_KEY || + process.env.OPENAI_API_KEY; + if (openaiKey) args.env.OPENAI_API_KEY = openaiKey; + + const codexModel = + args.group.agentConfig?.codexModel || + args.envVars.CODEX_MODEL || + process.env.CODEX_MODEL; + if (codexModel) args.env.CODEX_MODEL = codexModel; + + const codexEffort = + args.group.agentConfig?.codexEffort || + args.envVars.CODEX_EFFORT || + process.env.CODEX_EFFORT; + if (codexEffort) args.env.CODEX_EFFORT = codexEffort; + + const hostCodexDir = path.join(os.homedir(), '.codex'); + const sessionCodexDir = path.join(args.sessionRootDir, '.codex'); + fs.mkdirSync(sessionCodexDir, { recursive: true }); + + const authSrc = path.join(hostCodexDir, 'auth.json'); + const authDst = path.join(sessionCodexDir, 'auth.json'); + if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); + for (const file of ['config.toml', 'config.json']) { + const src = path.join(hostCodexDir, file); + const dst = path.join(sessionCodexDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dst); + } + } + + const overlayPath = path.join(args.groupDir, '.codex', 'config.toml'); + const sessionConfigPath = path.join(sessionCodexDir, 'config.toml'); + if (fs.existsSync(overlayPath)) { + const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim(); + if (overlayToml) { + const baseToml = fs.existsSync(sessionConfigPath) + ? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd() + : ''; + fs.writeFileSync( + sessionConfigPath, + [baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n', + ); + } + } + + const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); + const sessionAgents = [ + readPlatformPrompt('codex', args.projectRoot), + args.isPairedRoom + ? readPairedRoomPrompt('codex', args.projectRoot) + : undefined, + ] + .filter((value): value is string => Boolean(value)) + .join('\n\n---\n\n') + .trim(); + if (sessionAgents) { + fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n'); + } else if (fs.existsSync(sessionAgentsPath)) { + fs.unlinkSync(sessionAgentsPath); + } + + syncDirectoryEntries( + [ + path.join(os.homedir(), '.claude', 'skills'), + path.join(args.projectRoot, 'runners', 'skills'), + ], + path.join(sessionCodexDir, 'skills'), + ); + + const mcpServerPath = path.join( + args.projectRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + if (fs.existsSync(mcpServerPath)) { + let toml = fs.existsSync(sessionConfigPath) + ? fs.readFileSync(sessionConfigPath, 'utf-8') + : ''; + toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, ''); + toml = toml.replace( + /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, + '', + ); + const mcpSection = ` +[mcp_servers.nanoclaw] +command = "node" +args = [${JSON.stringify(mcpServerPath)}] + +[mcp_servers.nanoclaw.env] +NANOCLAW_IPC_DIR = ${JSON.stringify(args.env.NANOCLAW_IPC_DIR)} +NANOCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)} +NANOCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} +NANOCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} +NANOCLAW_AGENT_TYPE = ${JSON.stringify(args.env.NANOCLAW_AGENT_TYPE)} +`; + const mementoSseUrl = + args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; + const mementoAccessKey = + args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; + const mementoRemotePath = + args.envVars.MEMENTO_MCP_REMOTE_PATH || + process.env.MEMENTO_MCP_REMOTE_PATH || + 'mcp-remote'; + const mementoSection = mementoSseUrl + ? ` +[mcp_servers.memento-mcp] +command = ${JSON.stringify(mementoRemotePath)} +args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] +` + : ''; + fs.writeFileSync( + sessionConfigPath, + toml.trimEnd() + '\n' + mcpSection + mementoSection, + ); + } + + delete args.env.ANTHROPIC_API_KEY; + delete args.env.ANTHROPIC_AUTH_TOKEN; + delete args.env.ANTHROPIC_BASE_URL; + delete args.env.CLAUDE_CODE_OAUTH_TOKEN; + args.env.CODEX_HOME = sessionCodexDir; +} + +export interface PreparedGroupEnvironment { + env: Record; + groupDir: string; + runnerDir: string; +} + +export function prepareGroupEnvironment( + group: RegisteredGroup, + isMain: boolean, + chatJid: string, + options?: { + runtimeTaskId?: string; + useTaskScopedSession?: boolean; + }, +): PreparedGroupEnvironment { + const projectRoot = process.cwd(); + const groupDir = resolveGroupFolderPath(group.folder); + fs.mkdirSync(groupDir, { recursive: true }); + + const runtimeTaskId = options?.runtimeTaskId; + const useTaskScopedSession = + options?.useTaskScopedSession === true && Boolean(runtimeTaskId); + const sessionRootDir = + runtimeTaskId && useTaskScopedSession + ? resolveTaskSessionsPath(group.folder, runtimeTaskId) + : resolveGroupSessionsPath(group.folder); + + const groupSessionsDir = path.join(sessionRootDir, '.claude'); + fs.mkdirSync(groupSessionsDir, { recursive: true }); + ensureClaudeSessionSettings(groupSessionsDir); + + const workDirClaude = group.workDir + ? path.join(group.workDir, '.claude') + : null; + const skillSources = [ + path.join(os.homedir(), '.claude', 'skills'), + ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), + path.join(projectRoot, 'runners', 'skills'), + ]; + syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills')); + + const groupIpcDir = runtimeTaskId + ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId) + : resolveGroupIpcPath(group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + + const globalDir = path.join(GROUPS_DIR, 'global'); + const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); + const isPairedRoom = isPairedRoomJid(chatJid); + + const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); + const claudePairedRoomPrompt = isPairedRoom + ? readPairedRoomPrompt('claude-code', projectRoot) + : undefined; + const globalClaudeMemory = + !isMain && fs.existsSync(globalClaudeMdPath) + ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim() + : undefined; + const sessionClaudeMd = [ + claudePlatformPrompt, + claudePairedRoomPrompt, + globalClaudeMemory, + ] + .filter((value): value is string => Boolean(value)) + .join('\n\n---\n\n') + .trim(); + const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md'); + if (sessionClaudeMd) { + fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n'); + } else if (fs.existsSync(sessionClaudeMdPath)) { + fs.unlinkSync(sessionClaudeMdPath); + } + + const agentType = group.agentType || 'claude-code'; + const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; + const runnerDir = path.join(projectRoot, 'runners', runnerDirName); + + const envVars = readEnvFile([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_MODEL', + 'CLAUDE_THINKING', + 'CLAUDE_THINKING_BUDGET', + 'CLAUDE_EFFORT', + 'OPENAI_API_KEY', + 'CODEX_OPENAI_API_KEY', + 'CODEX_MODEL', + 'CODEX_EFFORT', + 'MEMENTO_MCP_SSE_URL', + 'MEMENTO_ACCESS_KEY', + 'MEMENTO_MCP_REMOTE_PATH', + ]); + + const env = buildBaseRunnerEnv({ + group, + chatJid, + isMain, + groupDir, + groupIpcDir, + globalDir, + groupSessionsDir, + agentType, + envVars, + }); + + if (agentType === 'codex') { + prepareCodexSessionEnvironment({ + env, + envVars, + projectRoot, + group, + groupDir, + sessionRootDir, + chatJid, + isMain, + isPairedRoom, + }); + } else { + prepareClaudeEnvironment({ env, envVars, group }); + } + + return { env, groupDir, runnerDir }; +} diff --git a/src/agent-runner-snapshot.ts b/src/agent-runner-snapshot.ts new file mode 100644 index 0000000..ef3c4eb --- /dev/null +++ b/src/agent-runner-snapshot.ts @@ -0,0 +1,59 @@ +import fs from 'fs'; +import path from 'path'; + +import { + resolveGroupIpcPath, + resolveTaskRuntimeIpcPath, +} from './group-folder.js'; + +export function writeTasksSnapshot( + groupFolder: string, + isMain: boolean, + tasks: Array<{ + id: string; + groupFolder: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string | null; + }>, + runtimeTaskId?: string, +): void { + const groupIpcDir = runtimeTaskId + ? resolveTaskRuntimeIpcPath(groupFolder, runtimeTaskId) + : resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + const filteredTasks = isMain + ? tasks + : tasks.filter((t) => t.groupFolder === groupFolder); + const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); + fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); +} + +export interface AvailableGroup { + jid: string; + name: string; + lastActivity: string; + isRegistered: boolean; +} + +export function writeGroupsSnapshot( + groupFolder: string, + isMain: boolean, + groups: AvailableGroup[], + _registeredJids?: Set, +): void { + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + const visibleGroups = isMain ? groups : []; + const groupsFile = path.join(groupIpcDir, 'available_groups.json'); + fs.writeFileSync( + groupsFile, + JSON.stringify( + { groups: visibleGroups, lastSync: new Date().toISOString() }, + null, + 2, + ), + ); +} diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 27b4ffe..d5ea6e2 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -4,30 +4,16 @@ */ import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; -import os from 'os'; import path from 'path'; -import { - AGENT_MAX_OUTPUT_SIZE, - AGENT_TIMEOUT, - GROUPS_DIR, - IDLE_TIMEOUT, - TIMEZONE, -} from './config.js'; -import { - resolveGroupFolderPath, - resolveGroupIpcPath, - resolveGroupSessionsPath, - resolveTaskRuntimeIpcPath, - resolveTaskSessionsPath, -} from './group-folder.js'; +import { AGENT_MAX_OUTPUT_SIZE, AGENT_TIMEOUT, IDLE_TIMEOUT } from './config.js'; +import { prepareGroupEnvironment } from './agent-runner-environment.js'; +export { + type AvailableGroup, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './agent-runner-snapshot.js'; import { logger } from './logger.js'; -import { readEnvFile } from './env.js'; -import { isPairedRoomJid } from './db.js'; -import { - readPairedRoomPrompt, - readPlatformPrompt, -} from './platform-prompts.js'; import { AgentType, RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) @@ -56,407 +42,6 @@ export interface AgentOutput { error?: string; } -function syncDirectoryEntries(sources: string[], destination: string): void { - for (const source of sources) { - if (!fs.existsSync(source)) continue; - for (const entry of fs.readdirSync(source)) { - const srcPath = path.join(source, entry); - const dstPath = path.join(destination, entry); - if (fs.statSync(srcPath).isDirectory()) { - fs.cpSync(srcPath, dstPath, { recursive: true }); - } else { - fs.mkdirSync(destination, { recursive: true }); - fs.copyFileSync(srcPath, dstPath); - } - } - } -} - -function ensureClaudeSessionSettings(groupSessionsDir: string): void { - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (fs.existsSync(settingsFile)) return; - - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); -} - -function buildBaseRunnerEnv(args: { - group: RegisteredGroup; - chatJid: string; - isMain: boolean; - groupDir: string; - groupIpcDir: string; - globalDir: string; - groupSessionsDir: string; - agentType: AgentType; - envVars: Record; -}): Record { - const cleanEnv = { ...(process.env as Record) }; - for (const [key, value] of Object.entries(args.envVars)) { - if (value && !cleanEnv[key]) cleanEnv[key] = value; - } - delete cleanEnv.CLAUDECODE; - delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; - - const nodeBin = path.dirname(process.execPath); - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin'); - const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin'; - const extraPaths = [nodeBin, npmGlobalBin].filter( - (candidate) => !currentPath.includes(candidate) && fs.existsSync(candidate), - ); - - return { - ...cleanEnv, - PATH: - extraPaths.length > 0 - ? `${extraPaths.join(':')}:${currentPath}` - : currentPath, - TZ: TIMEZONE, - HOME: os.homedir(), - NANOCLAW_GROUP_DIR: args.groupDir, - NANOCLAW_IPC_DIR: args.groupIpcDir, - NANOCLAW_GLOBAL_DIR: args.globalDir, - ...(args.group.workDir ? { NANOCLAW_WORK_DIR: args.group.workDir } : {}), - NANOCLAW_CHAT_JID: args.chatJid, - NANOCLAW_GROUP_FOLDER: args.group.folder, - NANOCLAW_IS_MAIN: args.isMain ? '1' : '0', - NANOCLAW_AGENT_TYPE: args.agentType, - CLAUDE_CONFIG_DIR: args.groupSessionsDir, - }; -} - -function prepareClaudeEnvironment(args: { - env: Record; - envVars: Record; - group: RegisteredGroup; -}): void { - if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { - args.env.ANTHROPIC_API_KEY = - args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; - } - if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) { - args.env.ANTHROPIC_AUTH_TOKEN = - args.envVars.ANTHROPIC_AUTH_TOKEN || - process.env.ANTHROPIC_AUTH_TOKEN || - ''; - } - if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) { - args.env.ANTHROPIC_BASE_URL = - args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || ''; - } - if ( - args.envVars.CLAUDE_CODE_OAUTH_TOKEN || - process.env.CLAUDE_CODE_OAUTH_TOKEN - ) { - args.env.CLAUDE_CODE_OAUTH_TOKEN = - args.envVars.CLAUDE_CODE_OAUTH_TOKEN || - process.env.CLAUDE_CODE_OAUTH_TOKEN || - ''; - } - for (const key of [ - 'CLAUDE_MODEL', - 'CLAUDE_THINKING', - 'CLAUDE_THINKING_BUDGET', - 'CLAUDE_EFFORT', - ]) { - const value = - args.envVars[key as keyof typeof args.envVars] || process.env[key]; - if (value) args.env[key] = value; - } - if (args.group.agentConfig?.claudeModel) { - args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel; - } - if (args.group.agentConfig?.claudeEffort) { - args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort; - } -} - -function prepareCodexSessionEnvironment(args: { - env: Record; - envVars: Record; - projectRoot: string; - group: RegisteredGroup; - groupDir: string; - sessionRootDir: string; - chatJid: string; - isMain: boolean; - isPairedRoom: boolean; -}): void { - const openaiKey = - args.envVars.CODEX_OPENAI_API_KEY || - process.env.CODEX_OPENAI_API_KEY || - args.envVars.OPENAI_API_KEY || - process.env.OPENAI_API_KEY; - if (openaiKey) args.env.OPENAI_API_KEY = openaiKey; - - const codexModel = - args.group.agentConfig?.codexModel || - args.envVars.CODEX_MODEL || - process.env.CODEX_MODEL; - if (codexModel) args.env.CODEX_MODEL = codexModel; - - const codexEffort = - args.group.agentConfig?.codexEffort || - args.envVars.CODEX_EFFORT || - process.env.CODEX_EFFORT; - if (codexEffort) args.env.CODEX_EFFORT = codexEffort; - - const hostCodexDir = path.join(os.homedir(), '.codex'); - const sessionCodexDir = path.join(args.sessionRootDir, '.codex'); - fs.mkdirSync(sessionCodexDir, { recursive: true }); - - const authSrc = path.join(hostCodexDir, 'auth.json'); - const authDst = path.join(sessionCodexDir, 'auth.json'); - if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); - for (const file of ['config.toml', 'config.json']) { - const src = path.join(hostCodexDir, file); - const dst = path.join(sessionCodexDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, dst); - } - } - - const overlayPath = path.join(args.groupDir, '.codex', 'config.toml'); - const sessionConfigPath = path.join(sessionCodexDir, 'config.toml'); - if (fs.existsSync(overlayPath)) { - const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim(); - if (overlayToml) { - const baseToml = fs.existsSync(sessionConfigPath) - ? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd() - : ''; - fs.writeFileSync( - sessionConfigPath, - [baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n', - ); - } - } - - const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); - const sessionAgents = [ - readPlatformPrompt('codex', args.projectRoot), - args.isPairedRoom - ? readPairedRoomPrompt('codex', args.projectRoot) - : undefined, - ] - .filter((value): value is string => Boolean(value)) - .join('\n\n---\n\n') - .trim(); - if (sessionAgents) { - fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n'); - } else if (fs.existsSync(sessionAgentsPath)) { - fs.unlinkSync(sessionAgentsPath); - } - - syncDirectoryEntries( - [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(args.projectRoot, 'runners', 'skills'), - ], - path.join(sessionCodexDir, 'skills'), - ); - - const mcpServerPath = path.join( - args.projectRoot, - 'runners', - 'agent-runner', - 'dist', - 'ipc-mcp-stdio.js', - ); - if (fs.existsSync(mcpServerPath)) { - let toml = fs.existsSync(sessionConfigPath) - ? fs.readFileSync(sessionConfigPath, 'utf-8') - : ''; - toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, ''); - toml = toml.replace( - /\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, - '', - ); - const mcpSection = ` -[mcp_servers.nanoclaw] -command = "node" -args = [${JSON.stringify(mcpServerPath)}] - -[mcp_servers.nanoclaw.env] -NANOCLAW_IPC_DIR = ${JSON.stringify(args.env.NANOCLAW_IPC_DIR)} -NANOCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)} -NANOCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)} -NANOCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')} -NANOCLAW_AGENT_TYPE = ${JSON.stringify(args.env.NANOCLAW_AGENT_TYPE)} -`; - const mementoSseUrl = - args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL; - const mementoAccessKey = - args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || ''; - const mementoRemotePath = - args.envVars.MEMENTO_MCP_REMOTE_PATH || - process.env.MEMENTO_MCP_REMOTE_PATH || - 'mcp-remote'; - const mementoSection = mementoSseUrl - ? ` -[mcp_servers.memento-mcp] -command = ${JSON.stringify(mementoRemotePath)} -args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}] -` - : ''; - fs.writeFileSync( - sessionConfigPath, - toml.trimEnd() + '\n' + mcpSection + mementoSection, - ); - } - - delete args.env.ANTHROPIC_API_KEY; - delete args.env.ANTHROPIC_AUTH_TOKEN; - delete args.env.ANTHROPIC_BASE_URL; - delete args.env.CLAUDE_CODE_OAUTH_TOKEN; - args.env.CODEX_HOME = sessionCodexDir; -} - -/** - * Prepare the group's environment: directories, sessions, env vars. - * Returns the environment variables and paths for the runner process. - */ -function prepareGroupEnvironment( - group: RegisteredGroup, - isMain: boolean, - chatJid: string, - options?: { - runtimeTaskId?: string; - useTaskScopedSession?: boolean; - }, -): { env: Record; groupDir: string; runnerDir: string } { - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const runtimeTaskId = options?.runtimeTaskId; - const useTaskScopedSession = - options?.useTaskScopedSession === true && Boolean(runtimeTaskId); - const sessionRootDir = - runtimeTaskId && useTaskScopedSession - ? resolveTaskSessionsPath(group.folder, runtimeTaskId) - : resolveGroupSessionsPath(group.folder); - - // Per-runtime Claude sessions directory - const groupSessionsDir = path.join(sessionRootDir, '.claude'); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - ensureClaudeSessionSettings(groupSessionsDir); - - // Sync skills into each group's .claude/ session dir - // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/ - const workDirClaude = group.workDir - ? path.join(group.workDir, '.claude') - : null; - const skillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), - path.join(projectRoot, 'runners', 'skills'), - ]; - syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills')); - - // Per-group IPC namespace - const groupIpcDir = runtimeTaskId - ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId) - : resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - - // Global memory directory (for non-main groups) - const globalDir = path.join(GROUPS_DIR, 'global'); - const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); - const isPairedRoom = isPairedRoomJid(chatJid); - - const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); - const claudePairedRoomPrompt = isPairedRoom - ? readPairedRoomPrompt('claude-code', projectRoot) - : undefined; - const globalClaudeMemory = - !isMain && fs.existsSync(globalClaudeMdPath) - ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim() - : undefined; - const sessionClaudeMd = [ - claudePlatformPrompt, - claudePairedRoomPrompt, - globalClaudeMemory, - ] - .filter((value): value is string => Boolean(value)) - .join('\n\n---\n\n') - .trim(); - const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md'); - if (sessionClaudeMd) { - fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n'); - } else if (fs.existsSync(sessionClaudeMdPath)) { - fs.unlinkSync(sessionClaudeMdPath); - } - - // Determine runner directory - const agentType = group.agentType || 'claude-code'; - const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; - const runnerDir = path.join(projectRoot, 'runners', runnerDirName); - - // Build environment variables for the runner process - const envVars = readEnvFile([ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'ANTHROPIC_BASE_URL', - 'CLAUDE_CODE_OAUTH_TOKEN', - 'CLAUDE_MODEL', - 'CLAUDE_THINKING', - 'CLAUDE_THINKING_BUDGET', - 'CLAUDE_EFFORT', - 'OPENAI_API_KEY', - 'CODEX_OPENAI_API_KEY', - 'CODEX_MODEL', - 'CODEX_EFFORT', - 'MEMENTO_MCP_SSE_URL', - 'MEMENTO_ACCESS_KEY', - 'MEMENTO_MCP_REMOTE_PATH', - ]); - - const env = buildBaseRunnerEnv({ - group, - chatJid, - isMain, - groupDir, - groupIpcDir, - globalDir, - groupSessionsDir, - agentType, - envVars, - }); - - // Pass credentials directly (no proxy needed on host) - if (agentType === 'codex') { - prepareCodexSessionEnvironment({ - env, - envVars, - projectRoot, - group, - groupDir, - sessionRootDir, - chatJid, - isMain, - isPairedRoom, - }); - } else { - prepareClaudeEnvironment({ env, envVars, group }); - } - - return { env, groupDir, runnerDir }; -} - export async function runAgentProcess( group: RegisteredGroup, input: AgentInput, @@ -868,55 +453,3 @@ export async function runAgentProcess( }); }); } - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, - runtimeTaskId?: string, -): void { - const groupIpcDir = runtimeTaskId - ? resolveTaskRuntimeIpcPath(groupFolder, runtimeTaskId) - : resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - _registeredJids?: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - const visibleGroups = isMain ? groups : []; - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { groups: visibleGroups, lastSync: new Date().toISOString() }, - null, - 2, - ), - ); -} diff --git a/src/dashboard-status-content.ts b/src/dashboard-status-content.ts new file mode 100644 index 0000000..78ba2fe --- /dev/null +++ b/src/dashboard-status-content.ts @@ -0,0 +1,61 @@ +import { STATUS_SHOW_ROOMS } from './config.js'; +import { + composeDashboardContent, + type DashboardRoomLine, + getStatusLabel, + renderCategorizedRoomSections, +} from './dashboard-render.js'; +import type { GroupQueue } from './group-queue.js'; +import type { AgentType, Channel, RegisteredGroup } from './types.js'; + +export interface DashboardOptions { + assistantName: string; + channels: Channel[]; + getSessions: () => Record; + queue: GroupQueue; + registeredGroups: () => Record; + statusChannelId: string; + statusUpdateInterval: number; + usageUpdateInterval: number; + serviceAgentType?: AgentType; +} + +function getSessionLabel(sessionId: string | undefined): string { + if (!sessionId) return '세션 없음'; + const shortId = sessionId.length > 8 ? sessionId.slice(-8) : sessionId; + return `세션 ${shortId}`; +} + +export function buildStatusContent(opts: DashboardOptions): string { + if (!STATUS_SHOW_ROOMS) return ''; + + const sessions = opts.getSessions(); + const groups = opts.registeredGroups(); + const statuses = opts.queue.getStatuses(Object.keys(groups)); + + let totalActive = 0; + let totalWaiting = 0; + const roomLines: DashboardRoomLine[] = statuses + .map((status) => { + const group = groups[status.jid]; + if (!group) return null; + if (status.status === 'processing') totalActive += 1; + if (status.status === 'waiting') totalWaiting += 1; + return { + category: '기타', + categoryPosition: 999, + position: 999, + line: ` **${group.name}** — ${getStatusLabel(status)} · ${getSessionLabel(sessions[group.folder])}`, + }; + }) + .filter((line): line is DashboardRoomLine => Boolean(line)); + + return composeDashboardContent([ + `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${roomLines.length}\n\n${renderCategorizedRoomSections( + { + lines: roomLines, + showCategoryHeaders: false, + }, + )}`, + ]); +} diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts index 9cf6f70..a687762 100644 --- a/src/dashboard.test.ts +++ b/src/dashboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { DashboardOptions } from './dashboard.js'; +import type { DashboardOptions } from './dashboard-status-content.js'; function makeOptions(sessionId?: string): DashboardOptions { const sessions: Record = sessionId @@ -42,7 +42,7 @@ describe('buildStatusContent', () => { ): Promise<(opts: DashboardOptions) => string> { vi.resetModules(); process.env.STATUS_SHOW_ROOMS = statusShowRooms; - const mod = await import('./dashboard.js'); + const mod = await import('./dashboard-status-content.js'); return mod.buildStatusContent; } diff --git a/src/dashboard.ts b/src/dashboard.ts index 38ac35d..52d6ea9 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,82 +1,14 @@ -import { STATUS_SHOW_ROOMS } from './config.js'; import { - composeDashboardContent, - type DashboardRoomLine, - getStatusLabel, - renderCategorizedRoomSections, -} from './dashboard-render.js'; -import type { GroupQueue } from './group-queue.js'; -import { startUnifiedDashboard } from './unified-dashboard.js'; -import type { AgentType, Channel, RegisteredGroup } from './types.js'; + buildStatusContent, + type DashboardOptions, +} from './dashboard-status-content.js'; +import { + purgeDashboardChannel, + startUnifiedDashboard, +} from './unified-dashboard.js'; -export interface DashboardOptions { - assistantName: string; - channels: Channel[]; - getSessions: () => Record; - queue: GroupQueue; - registeredGroups: () => Record; - statusChannelId: string; - statusUpdateInterval: number; - usageUpdateInterval: number; - serviceAgentType?: AgentType; -} - -function getSessionLabel(sessionId: string | undefined): string { - if (!sessionId) return '세션 없음'; - const shortId = sessionId.length > 8 ? sessionId.slice(-8) : sessionId; - return `세션 ${shortId}`; -} - -export function buildStatusContent(opts: DashboardOptions): string { - if (!STATUS_SHOW_ROOMS) return ''; - - const sessions = opts.getSessions(); - const groups = opts.registeredGroups(); - const statuses = opts.queue.getStatuses(Object.keys(groups)); - - let totalActive = 0; - let totalWaiting = 0; - const roomLines: DashboardRoomLine[] = statuses - .map((status) => { - const group = groups[status.jid]; - if (!group) return null; - if (status.status === 'processing') totalActive += 1; - if (status.status === 'waiting') totalWaiting += 1; - return { - category: '기타', - categoryPosition: 999, - position: 999, - line: ` **${group.name}** — ${getStatusLabel(status)} · ${getSessionLabel(sessions[group.folder])}`, - }; - }) - .filter((line): line is DashboardRoomLine => Boolean(line)); - - return composeDashboardContent([ - `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${roomLines.length}\n\n${renderCategorizedRoomSections( - { - lines: roomLines, - showCategoryHeaders: false, - }, - )}`, - ]); -} - -export async function purgeDashboardChannel( - opts: Pick, -): Promise { - if (!opts.statusChannelId) return; - - const statusJid = `dc:${opts.statusChannelId}`; - const channel = opts.channels.find( - (item) => - item.name.startsWith('discord') && - item.isConnected() && - item.purgeChannel, - ); - if (channel?.purgeChannel) { - await channel.purgeChannel(statusJid); - } -} +export { buildStatusContent, purgeDashboardChannel }; +export type { DashboardOptions }; export async function startStatusDashboard( opts: DashboardOptions, diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 8656be3..2e42cf7 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -91,7 +91,7 @@ function findDiscordChannel(channels: Channel[]): Channel | undefined { ); } -async function purgeDashboardChannel( +export async function purgeDashboardChannel( opts: Pick, ): Promise { if (!opts.statusChannelId) return;