654 lines
22 KiB
TypeScript
654 lines
22 KiB
TypeScript
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
|
import { constants } from 'fs';
|
|
import { join, normalize } from 'path';
|
|
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
|
import { withConfigLock } from './config-mutex';
|
|
import { expandPath, getOpenClawConfigDir } from './paths';
|
|
import * as logger from './logger';
|
|
|
|
const MAIN_AGENT_ID = 'main';
|
|
const MAIN_AGENT_NAME = 'Main';
|
|
const DEFAULT_ACCOUNT_ID = 'default';
|
|
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
|
|
const AGENT_BOOTSTRAP_FILES = [
|
|
'AGENTS.md',
|
|
'SOUL.md',
|
|
'TOOLS.md',
|
|
'USER.md',
|
|
'IDENTITY.md',
|
|
'HEARTBEAT.md',
|
|
'BOOT.md',
|
|
];
|
|
const AGENT_RUNTIME_FILES = [
|
|
'auth-profiles.json',
|
|
'models.json',
|
|
];
|
|
|
|
interface AgentModelConfig {
|
|
primary?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface AgentDefaultsConfig {
|
|
workspace?: string;
|
|
model?: string | AgentModelConfig;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface AgentListEntry extends Record<string, unknown> {
|
|
id: string;
|
|
name?: string;
|
|
default?: boolean;
|
|
workspace?: string;
|
|
agentDir?: string;
|
|
model?: string | AgentModelConfig;
|
|
}
|
|
|
|
interface AgentsConfig extends Record<string, unknown> {
|
|
defaults?: AgentDefaultsConfig;
|
|
list?: AgentListEntry[];
|
|
}
|
|
|
|
interface BindingMatch extends Record<string, unknown> {
|
|
channel?: string;
|
|
accountId?: string;
|
|
}
|
|
|
|
interface BindingConfig extends Record<string, unknown> {
|
|
agentId?: string;
|
|
match?: BindingMatch;
|
|
}
|
|
|
|
interface ChannelSectionConfig extends Record<string, unknown> {
|
|
accounts?: Record<string, Record<string, unknown>>;
|
|
defaultAccount?: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
interface AgentConfigDocument extends Record<string, unknown> {
|
|
agents?: AgentsConfig;
|
|
bindings?: BindingConfig[];
|
|
channels?: Record<string, ChannelSectionConfig>;
|
|
session?: {
|
|
mainKey?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
}
|
|
|
|
export interface AgentSummary {
|
|
id: string;
|
|
name: string;
|
|
isDefault: boolean;
|
|
modelDisplay: string;
|
|
inheritedModel: boolean;
|
|
workspace: string;
|
|
agentDir: string;
|
|
mainSessionKey: string;
|
|
channelTypes: string[];
|
|
}
|
|
|
|
export interface AgentsSnapshot {
|
|
agents: AgentSummary[];
|
|
defaultAgentId: string;
|
|
configuredChannelTypes: string[];
|
|
channelOwners: Record<string, string>;
|
|
}
|
|
|
|
function formatModelLabel(model: unknown): string | null {
|
|
if (typeof model === 'string' && model.trim()) {
|
|
const trimmed = model.trim();
|
|
const parts = trimmed.split('/');
|
|
return parts[parts.length - 1] || trimmed;
|
|
}
|
|
|
|
if (model && typeof model === 'object') {
|
|
const primary = (model as AgentModelConfig).primary;
|
|
if (typeof primary === 'string' && primary.trim()) {
|
|
const parts = primary.trim().split('/');
|
|
return parts[parts.length - 1] || primary.trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeAgentName(name: string): string {
|
|
return name.trim() || 'Agent';
|
|
}
|
|
|
|
function slugifyAgentId(name: string): string {
|
|
const normalized = name
|
|
.normalize('NFKD')
|
|
.replace(/[^\w\s-]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[_\s]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
|
|
if (!normalized) return 'agent';
|
|
if (normalized === MAIN_AGENT_ID) return 'agent';
|
|
return normalized;
|
|
}
|
|
|
|
async function fileExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function ensureDir(path: string): Promise<void> {
|
|
if (!(await fileExists(path))) {
|
|
await mkdir(path, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function getDefaultWorkspacePath(config: AgentConfigDocument): string {
|
|
const defaults = (config.agents && typeof config.agents === 'object'
|
|
? (config.agents as AgentsConfig).defaults
|
|
: undefined);
|
|
return typeof defaults?.workspace === 'string' && defaults.workspace.trim()
|
|
? defaults.workspace
|
|
: DEFAULT_WORKSPACE_PATH;
|
|
}
|
|
|
|
function getDefaultAgentDirPath(agentId: string): string {
|
|
return `~/.openclaw/agents/${agentId}/agent`;
|
|
}
|
|
|
|
function createImplicitMainEntry(config: AgentConfigDocument): AgentListEntry {
|
|
return {
|
|
id: MAIN_AGENT_ID,
|
|
name: MAIN_AGENT_NAME,
|
|
default: true,
|
|
workspace: getDefaultWorkspacePath(config),
|
|
agentDir: getDefaultAgentDirPath(MAIN_AGENT_ID),
|
|
};
|
|
}
|
|
|
|
function normalizeAgentsConfig(config: AgentConfigDocument): {
|
|
agentsConfig: AgentsConfig;
|
|
entries: AgentListEntry[];
|
|
defaultAgentId: string;
|
|
syntheticMain: boolean;
|
|
} {
|
|
const agentsConfig = (config.agents && typeof config.agents === 'object'
|
|
? { ...(config.agents as AgentsConfig) }
|
|
: {}) as AgentsConfig;
|
|
const rawEntries = Array.isArray(agentsConfig.list)
|
|
? agentsConfig.list.filter((entry): entry is AgentListEntry => (
|
|
Boolean(entry) && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim().length > 0
|
|
))
|
|
: [];
|
|
|
|
if (rawEntries.length === 0) {
|
|
const main = createImplicitMainEntry(config);
|
|
return {
|
|
agentsConfig,
|
|
entries: [main],
|
|
defaultAgentId: MAIN_AGENT_ID,
|
|
syntheticMain: true,
|
|
};
|
|
}
|
|
|
|
const defaultEntry = rawEntries.find((entry) => entry.default) ?? rawEntries[0];
|
|
return {
|
|
agentsConfig,
|
|
entries: rawEntries.map((entry) => ({ ...entry })),
|
|
defaultAgentId: defaultEntry.id,
|
|
syntheticMain: false,
|
|
};
|
|
}
|
|
|
|
function isChannelBinding(binding: unknown): binding is BindingConfig {
|
|
if (!binding || typeof binding !== 'object') return false;
|
|
const candidate = binding as BindingConfig;
|
|
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
|
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false;
|
|
if (typeof candidate.match.channel !== 'string' || !candidate.match.channel) return false;
|
|
const keys = Object.keys(candidate.match);
|
|
// Accept bindings with just {channel} or {channel, accountId}
|
|
if (keys.length === 1 && keys[0] === 'channel') return true;
|
|
if (keys.length === 2 && keys.includes('channel') && keys.includes('accountId')) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Normalize agent ID for consistent comparison (bindings vs entries). */
|
|
function normalizeAgentIdForBinding(id: string): string {
|
|
return (id ?? '').trim().toLowerCase() || '';
|
|
}
|
|
|
|
function normalizeMainKey(value: unknown): string {
|
|
if (typeof value !== 'string') return 'main';
|
|
const trimmed = value.trim().toLowerCase();
|
|
return trimmed || 'main';
|
|
}
|
|
|
|
function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): string {
|
|
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
|
|
}
|
|
|
|
/**
|
|
* Returns a map of channelType -> agentId from bindings.
|
|
* Account-scoped bindings are preferred; channel-wide bindings serve as fallback.
|
|
* Multiple agents can own the same channel type (different accounts).
|
|
*/
|
|
function getChannelBindingMap(bindings: unknown): {
|
|
channelToAgent: Map<string, string>;
|
|
accountToAgent: Map<string, string>;
|
|
} {
|
|
const channelToAgent = new Map<string, string>();
|
|
const accountToAgent = new Map<string, string>();
|
|
if (!Array.isArray(bindings)) return { channelToAgent, accountToAgent };
|
|
|
|
for (const binding of bindings) {
|
|
if (!isChannelBinding(binding)) continue;
|
|
const agentId = normalizeAgentIdForBinding(binding.agentId!);
|
|
const channel = binding.match?.channel;
|
|
if (!agentId || !channel) continue;
|
|
|
|
const accountId = binding.match?.accountId;
|
|
if (accountId) {
|
|
accountToAgent.set(`${channel}:${accountId}`, agentId);
|
|
} else {
|
|
channelToAgent.set(channel, agentId);
|
|
}
|
|
}
|
|
|
|
return { channelToAgent, accountToAgent };
|
|
}
|
|
|
|
function upsertBindingsForChannel(
|
|
bindings: unknown,
|
|
channelType: string,
|
|
agentId: string | null,
|
|
accountId?: string,
|
|
): BindingConfig[] | undefined {
|
|
const nextBindings = Array.isArray(bindings)
|
|
? [...bindings as BindingConfig[]].filter((binding) => {
|
|
if (!isChannelBinding(binding)) return true;
|
|
if (binding.match?.channel !== channelType) return true;
|
|
// Only remove binding that matches the exact accountId scope
|
|
if (accountId) {
|
|
return binding.match?.accountId !== accountId;
|
|
}
|
|
// No accountId: remove channel-wide binding (legacy)
|
|
return Boolean(binding.match?.accountId);
|
|
})
|
|
: [];
|
|
|
|
if (agentId) {
|
|
const match: BindingMatch = { channel: channelType };
|
|
if (accountId) {
|
|
match.accountId = accountId;
|
|
}
|
|
nextBindings.push({ agentId, match });
|
|
}
|
|
|
|
return nextBindings.length > 0 ? nextBindings : undefined;
|
|
}
|
|
|
|
async function listExistingAgentIdsOnDisk(): Promise<Set<string>> {
|
|
const ids = new Set<string>();
|
|
const agentsDir = join(getOpenClawConfigDir(), 'agents');
|
|
|
|
try {
|
|
if (!(await fileExists(agentsDir))) return ids;
|
|
const entries = await readdir(agentsDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) ids.add(entry.name);
|
|
}
|
|
} catch {
|
|
// ignore discovery failures
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
async function removeAgentRuntimeDirectory(agentId: string): Promise<void> {
|
|
const runtimeDir = join(getOpenClawConfigDir(), 'agents', agentId);
|
|
try {
|
|
await rm(runtimeDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
logger.warn('Failed to remove agent runtime directory', {
|
|
agentId,
|
|
runtimeDir,
|
|
error: String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
function trimTrailingSeparators(path: string): string {
|
|
return path.replace(/[\\/]+$/, '');
|
|
}
|
|
|
|
function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
|
|
if (agent.id === MAIN_AGENT_ID) return null;
|
|
|
|
const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
|
|
const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`);
|
|
const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace));
|
|
const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace));
|
|
|
|
return normalizedConfigured === normalizedManaged ? configuredWorkspace : null;
|
|
}
|
|
|
|
export async function removeAgentWorkspaceDirectory(agent: { id: string; workspace?: string }): Promise<void> {
|
|
const workspaceDir = getManagedWorkspaceDirectory(agent as AgentListEntry);
|
|
if (!workspaceDir) {
|
|
logger.warn('Skipping agent workspace deletion for unmanaged path', {
|
|
agentId: agent.id,
|
|
workspace: agent.workspace,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await rm(workspaceDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
logger.warn('Failed to remove agent workspace directory', {
|
|
agentId: agent.id,
|
|
workspaceDir,
|
|
error: String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function copyBootstrapFiles(sourceWorkspace: string, targetWorkspace: string): Promise<void> {
|
|
await ensureDir(targetWorkspace);
|
|
|
|
for (const fileName of AGENT_BOOTSTRAP_FILES) {
|
|
const source = join(sourceWorkspace, fileName);
|
|
const target = join(targetWorkspace, fileName);
|
|
if (!(await fileExists(source)) || (await fileExists(target))) continue;
|
|
await copyFile(source, target);
|
|
}
|
|
}
|
|
|
|
async function copyRuntimeFiles(sourceAgentDir: string, targetAgentDir: string): Promise<void> {
|
|
await ensureDir(targetAgentDir);
|
|
|
|
for (const fileName of AGENT_RUNTIME_FILES) {
|
|
const source = join(sourceAgentDir, fileName);
|
|
const target = join(targetAgentDir, fileName);
|
|
if (!(await fileExists(source)) || (await fileExists(target))) continue;
|
|
await copyFile(source, target);
|
|
}
|
|
}
|
|
|
|
async function provisionAgentFilesystem(config: AgentConfigDocument, agent: AgentListEntry): Promise<void> {
|
|
const { entries } = normalizeAgentsConfig(config);
|
|
const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config);
|
|
const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config));
|
|
const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
|
|
const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID));
|
|
const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id));
|
|
const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions');
|
|
|
|
await ensureDir(targetWorkspace);
|
|
await ensureDir(targetAgentDir);
|
|
await ensureDir(targetSessionsDir);
|
|
|
|
if (targetWorkspace !== sourceWorkspace) {
|
|
await copyBootstrapFiles(sourceWorkspace, targetWorkspace);
|
|
}
|
|
if (targetAgentDir !== sourceAgentDir) {
|
|
await copyRuntimeFiles(sourceAgentDir, targetAgentDir);
|
|
}
|
|
}
|
|
|
|
export function resolveAccountIdForAgent(agentId: string): string {
|
|
return agentId === MAIN_AGENT_ID ? DEFAULT_ACCOUNT_ID : agentId;
|
|
}
|
|
|
|
function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channelType: string): string[] {
|
|
const channelSection = config.channels?.[channelType];
|
|
if (!channelSection || channelSection.enabled === false) {
|
|
return [];
|
|
}
|
|
|
|
const accounts = channelSection.accounts;
|
|
if (!accounts || typeof accounts !== 'object' || Object.keys(accounts).length === 0) {
|
|
return [DEFAULT_ACCOUNT_ID];
|
|
}
|
|
|
|
return Object.keys(accounts)
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
|
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
|
return a.localeCompare(b);
|
|
});
|
|
}
|
|
|
|
async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<AgentsSnapshot> {
|
|
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
|
const configuredChannels = await listConfiguredChannels();
|
|
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
|
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
|
const channelOwners: Record<string, string> = {};
|
|
|
|
// Build per-agent channel lists from account-scoped bindings
|
|
const agentChannelSets = new Map<string, Set<string>>();
|
|
|
|
for (const channelType of configuredChannels) {
|
|
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
|
|
let primaryOwner: string | undefined;
|
|
|
|
for (const accountId of accountIds) {
|
|
const owner =
|
|
accountToAgent.get(`${channelType}:${accountId}`)
|
|
|| (accountId === DEFAULT_ACCOUNT_ID ? (channelToAgent.get(channelType) || defaultAgentIdNorm) : undefined);
|
|
|
|
if (!owner) {
|
|
continue;
|
|
}
|
|
|
|
primaryOwner ??= owner;
|
|
const existing = agentChannelSets.get(owner) ?? new Set();
|
|
existing.add(channelType);
|
|
agentChannelSets.set(owner, existing);
|
|
}
|
|
|
|
if (!primaryOwner) {
|
|
primaryOwner = channelToAgent.get(channelType) || defaultAgentIdNorm;
|
|
const existing = agentChannelSets.get(primaryOwner) ?? new Set();
|
|
existing.add(channelType);
|
|
agentChannelSets.set(primaryOwner, existing);
|
|
}
|
|
|
|
channelOwners[channelType] = primaryOwner;
|
|
}
|
|
|
|
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
|
const agents: AgentSummary[] = entries.map((entry) => {
|
|
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
|
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
|
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
|
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
|
return {
|
|
id: entry.id,
|
|
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
|
isDefault: entry.id === defaultAgentId,
|
|
modelDisplay: modelLabel,
|
|
inheritedModel,
|
|
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
|
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
|
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
|
channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)),
|
|
};
|
|
});
|
|
|
|
return {
|
|
agents,
|
|
defaultAgentId,
|
|
configuredChannelTypes: configuredChannels,
|
|
channelOwners,
|
|
};
|
|
}
|
|
|
|
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
return buildSnapshotFromConfig(config);
|
|
}
|
|
|
|
export async function listConfiguredAgentIds(): Promise<string[]> {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
const { entries } = normalizeAgentsConfig(config);
|
|
const ids = [...new Set(entries.map((entry) => entry.id.trim()).filter(Boolean))];
|
|
return ids.length > 0 ? ids : [MAIN_AGENT_ID];
|
|
}
|
|
|
|
export async function createAgent(name: string): Promise<AgentsSnapshot> {
|
|
return withConfigLock(async () => {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
|
const normalizedName = normalizeAgentName(name);
|
|
const existingIds = new Set(entries.map((entry) => entry.id));
|
|
const diskIds = await listExistingAgentIdsOnDisk();
|
|
let nextId = slugifyAgentId(normalizedName);
|
|
let suffix = 2;
|
|
|
|
while (existingIds.has(nextId) || diskIds.has(nextId)) {
|
|
nextId = `${slugifyAgentId(normalizedName)}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
|
|
const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries];
|
|
const newAgent: AgentListEntry = {
|
|
id: nextId,
|
|
name: normalizedName,
|
|
workspace: `~/.openclaw/workspace-${nextId}`,
|
|
agentDir: getDefaultAgentDirPath(nextId),
|
|
};
|
|
|
|
if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) {
|
|
nextEntries.unshift(createImplicitMainEntry(config));
|
|
}
|
|
nextEntries.push(newAgent);
|
|
|
|
config.agents = {
|
|
...agentsConfig,
|
|
list: nextEntries,
|
|
};
|
|
|
|
await provisionAgentFilesystem(config, newAgent);
|
|
await writeOpenClawConfig(config);
|
|
logger.info('Created agent config entry', { agentId: nextId });
|
|
return buildSnapshotFromConfig(config);
|
|
});
|
|
}
|
|
|
|
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
|
|
return withConfigLock(async () => {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
|
const normalizedName = normalizeAgentName(name);
|
|
const index = entries.findIndex((entry) => entry.id === agentId);
|
|
if (index === -1) {
|
|
throw new Error(`Agent "${agentId}" not found`);
|
|
}
|
|
|
|
entries[index] = {
|
|
...entries[index],
|
|
name: normalizedName,
|
|
};
|
|
|
|
config.agents = {
|
|
...agentsConfig,
|
|
list: entries,
|
|
};
|
|
|
|
await writeOpenClawConfig(config);
|
|
logger.info('Updated agent name', { agentId, name: normalizedName });
|
|
return buildSnapshotFromConfig(config);
|
|
});
|
|
}
|
|
|
|
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
|
return withConfigLock(async () => {
|
|
if (agentId === MAIN_AGENT_ID) {
|
|
throw new Error('The main agent cannot be deleted');
|
|
}
|
|
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
|
|
const removedEntry = entries.find((entry) => entry.id === agentId);
|
|
const nextEntries = entries.filter((entry) => entry.id !== agentId);
|
|
if (!removedEntry || nextEntries.length === entries.length) {
|
|
throw new Error(`Agent "${agentId}" not found`);
|
|
}
|
|
|
|
config.agents = {
|
|
...agentsConfig,
|
|
list: nextEntries,
|
|
};
|
|
config.bindings = Array.isArray(config.bindings)
|
|
? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId))
|
|
: undefined;
|
|
|
|
if (defaultAgentId === agentId && nextEntries.length > 0) {
|
|
nextEntries[0] = {
|
|
...nextEntries[0],
|
|
default: true,
|
|
};
|
|
}
|
|
|
|
await writeOpenClawConfig(config);
|
|
await deleteAgentChannelAccounts(agentId);
|
|
await removeAgentRuntimeDirectory(agentId);
|
|
// NOTE: workspace directory is NOT deleted here intentionally.
|
|
// The caller (route handler) defers workspace removal until after
|
|
// the Gateway process has fully restarted, so that any in-flight
|
|
// process.chdir(workspace) calls complete before the directory
|
|
// disappears (otherwise process.cwd() throws ENOENT for the rest
|
|
// of the Gateway's lifetime).
|
|
logger.info('Deleted agent config entry', { agentId });
|
|
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
|
});
|
|
}
|
|
|
|
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
|
return withConfigLock(async () => {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
const { entries } = normalizeAgentsConfig(config);
|
|
if (!entries.some((entry) => entry.id === agentId)) {
|
|
throw new Error(`Agent "${agentId}" not found`);
|
|
}
|
|
|
|
const accountId = resolveAccountIdForAgent(agentId);
|
|
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
|
|
await writeOpenClawConfig(config);
|
|
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
|
return buildSnapshotFromConfig(config);
|
|
});
|
|
}
|
|
|
|
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
|
|
return withConfigLock(async () => {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
|
await writeOpenClawConfig(config);
|
|
logger.info('Cleared channel binding', { channelType, accountId });
|
|
return buildSnapshotFromConfig(config);
|
|
});
|
|
}
|
|
|
|
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
|
return withConfigLock(async () => {
|
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
|
if (!Array.isArray(config.bindings)) return;
|
|
|
|
const nextBindings = config.bindings.filter((binding) => {
|
|
if (!isChannelBinding(binding)) return true;
|
|
return binding.match?.channel !== channelType;
|
|
});
|
|
|
|
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
|
await writeOpenClawConfig(config);
|
|
logger.info('Cleared all bindings for channel', { channelType });
|
|
});
|
|
}
|