refactor: split runner env and slim dashboard facade
This commit is contained in:
414
src/agent-runner-environment.ts
Normal file
414
src/agent-runner-environment.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}): Record<string, string> {
|
||||||
|
const cleanEnv = { ...(process.env as Record<string, string>) };
|
||||||
|
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<string, string>;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
59
src/agent-runner-snapshot.ts
Normal file
59
src/agent-runner-snapshot.ts
Normal file
@@ -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<string>,
|
||||||
|
): 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,30 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
import { ChildProcess, spawn } from 'child_process';
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import { AGENT_MAX_OUTPUT_SIZE, AGENT_TIMEOUT, IDLE_TIMEOUT } from './config.js';
|
||||||
AGENT_MAX_OUTPUT_SIZE,
|
import { prepareGroupEnvironment } from './agent-runner-environment.js';
|
||||||
AGENT_TIMEOUT,
|
export {
|
||||||
GROUPS_DIR,
|
type AvailableGroup,
|
||||||
IDLE_TIMEOUT,
|
writeGroupsSnapshot,
|
||||||
TIMEZONE,
|
writeTasksSnapshot,
|
||||||
} from './config.js';
|
} from './agent-runner-snapshot.js';
|
||||||
import {
|
|
||||||
resolveGroupFolderPath,
|
|
||||||
resolveGroupIpcPath,
|
|
||||||
resolveGroupSessionsPath,
|
|
||||||
resolveTaskRuntimeIpcPath,
|
|
||||||
resolveTaskSessionsPath,
|
|
||||||
} from './group-folder.js';
|
|
||||||
import { logger } from './logger.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';
|
import { AgentType, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||||
@@ -56,407 +42,6 @@ export interface AgentOutput {
|
|||||||
error?: string;
|
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<string, string>;
|
|
||||||
}): Record<string, string> {
|
|
||||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
|
||||||
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<string, string>;
|
|
||||||
envVars: Record<string, string>;
|
|
||||||
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<string, string>;
|
|
||||||
envVars: Record<string, string>;
|
|
||||||
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<string, string>; 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(
|
export async function runAgentProcess(
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
input: AgentInput,
|
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<string>,
|
|
||||||
): 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
61
src/dashboard-status-content.ts
Normal file
61
src/dashboard-status-content.ts
Normal file
@@ -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<string, string>;
|
||||||
|
queue: GroupQueue;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 {
|
function makeOptions(sessionId?: string): DashboardOptions {
|
||||||
const sessions: Record<string, string> = sessionId
|
const sessions: Record<string, string> = sessionId
|
||||||
@@ -42,7 +42,7 @@ describe('buildStatusContent', () => {
|
|||||||
): Promise<(opts: DashboardOptions) => string> {
|
): Promise<(opts: DashboardOptions) => string> {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
process.env.STATUS_SHOW_ROOMS = statusShowRooms;
|
process.env.STATUS_SHOW_ROOMS = statusShowRooms;
|
||||||
const mod = await import('./dashboard.js');
|
const mod = await import('./dashboard-status-content.js');
|
||||||
return mod.buildStatusContent;
|
return mod.buildStatusContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,14 @@
|
|||||||
import { STATUS_SHOW_ROOMS } from './config.js';
|
|
||||||
import {
|
import {
|
||||||
composeDashboardContent,
|
buildStatusContent,
|
||||||
type DashboardRoomLine,
|
type DashboardOptions,
|
||||||
getStatusLabel,
|
} from './dashboard-status-content.js';
|
||||||
renderCategorizedRoomSections,
|
import {
|
||||||
} from './dashboard-render.js';
|
purgeDashboardChannel,
|
||||||
import type { GroupQueue } from './group-queue.js';
|
startUnifiedDashboard,
|
||||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
} from './unified-dashboard.js';
|
||||||
import type { AgentType, Channel, RegisteredGroup } from './types.js';
|
|
||||||
|
|
||||||
export interface DashboardOptions {
|
export { buildStatusContent, purgeDashboardChannel };
|
||||||
assistantName: string;
|
export type { DashboardOptions };
|
||||||
channels: Channel[];
|
|
||||||
getSessions: () => Record<string, string>;
|
|
||||||
queue: GroupQueue;
|
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
|
||||||
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<DashboardOptions, 'channels' | 'statusChannelId'>,
|
|
||||||
): Promise<void> {
|
|
||||||
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 async function startStatusDashboard(
|
export async function startStatusDashboard(
|
||||||
opts: DashboardOptions,
|
opts: DashboardOptions,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purgeDashboardChannel(
|
export async function purgeDashboardChannel(
|
||||||
opts: Pick<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
|
opts: Pick<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!opts.statusChannelId) return;
|
if (!opts.statusChannelId) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user