| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import type { GatewayRequestHandlers, RespondFn } from "./types.js"; |
| import { |
| listAgentIds, |
| resolveAgentDir, |
| resolveAgentWorkspaceDir, |
| } from "../../agents/agent-scope.js"; |
| import { |
| DEFAULT_AGENTS_FILENAME, |
| DEFAULT_BOOTSTRAP_FILENAME, |
| DEFAULT_HEARTBEAT_FILENAME, |
| DEFAULT_IDENTITY_FILENAME, |
| DEFAULT_MEMORY_ALT_FILENAME, |
| DEFAULT_MEMORY_FILENAME, |
| DEFAULT_SOUL_FILENAME, |
| DEFAULT_TOOLS_FILENAME, |
| DEFAULT_USER_FILENAME, |
| ensureAgentWorkspace, |
| isWorkspaceOnboardingCompleted, |
| } from "../../agents/workspace.js"; |
| import { movePathToTrash } from "../../browser/trash.js"; |
| import { |
| applyAgentConfig, |
| findAgentEntryIndex, |
| listAgentEntries, |
| pruneAgentConfig, |
| } from "../../commands/agents.config.js"; |
| import { loadConfig, writeConfigFile } from "../../config/config.js"; |
| import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; |
| import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; |
| import { resolveUserPath } from "../../utils.js"; |
| import { |
| ErrorCodes, |
| errorShape, |
| formatValidationErrors, |
| validateAgentsCreateParams, |
| validateAgentsDeleteParams, |
| validateAgentsFilesGetParams, |
| validateAgentsFilesListParams, |
| validateAgentsFilesSetParams, |
| validateAgentsListParams, |
| validateAgentsUpdateParams, |
| } from "../protocol/index.js"; |
| import { listAgentsForGateway } from "../session-utils.js"; |
|
|
| const BOOTSTRAP_FILE_NAMES = [ |
| DEFAULT_AGENTS_FILENAME, |
| DEFAULT_SOUL_FILENAME, |
| DEFAULT_TOOLS_FILENAME, |
| DEFAULT_IDENTITY_FILENAME, |
| DEFAULT_USER_FILENAME, |
| DEFAULT_HEARTBEAT_FILENAME, |
| DEFAULT_BOOTSTRAP_FILENAME, |
| ] as const; |
| const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter( |
| (name) => name !== DEFAULT_BOOTSTRAP_FILENAME, |
| ); |
|
|
| const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; |
|
|
| const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); |
|
|
| function resolveAgentWorkspaceFileOrRespondError( |
| params: Record<string, unknown>, |
| respond: RespondFn, |
| ): { |
| cfg: ReturnType<typeof loadConfig>; |
| agentId: string; |
| workspaceDir: string; |
| name: string; |
| } | null { |
| const cfg = loadConfig(); |
| const rawAgentId = params.agentId; |
| const agentId = resolveAgentIdOrError( |
| typeof rawAgentId === "string" || typeof rawAgentId === "number" ? String(rawAgentId) : "", |
| cfg, |
| ); |
| if (!agentId) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); |
| return null; |
| } |
| const rawName = params.name; |
| const name = ( |
| typeof rawName === "string" || typeof rawName === "number" ? String(rawName) : "" |
| ).trim(); |
| if (!ALLOWED_FILE_NAMES.has(name)) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`)); |
| return null; |
| } |
| const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); |
| return { cfg, agentId, workspaceDir, name }; |
| } |
|
|
| type FileMeta = { |
| size: number; |
| updatedAtMs: number; |
| }; |
|
|
| async function statFile(filePath: string): Promise<FileMeta | null> { |
| try { |
| const stat = await fs.stat(filePath); |
| if (!stat.isFile()) { |
| return null; |
| } |
| return { |
| size: stat.size, |
| updatedAtMs: Math.floor(stat.mtimeMs), |
| }; |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) { |
| const files: Array<{ |
| name: string; |
| path: string; |
| missing: boolean; |
| size?: number; |
| updatedAtMs?: number; |
| }> = []; |
|
|
| const bootstrapFileNames = options?.hideBootstrap |
| ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING |
| : BOOTSTRAP_FILE_NAMES; |
| for (const name of bootstrapFileNames) { |
| const filePath = path.join(workspaceDir, name); |
| const meta = await statFile(filePath); |
| if (meta) { |
| files.push({ |
| name, |
| path: filePath, |
| missing: false, |
| size: meta.size, |
| updatedAtMs: meta.updatedAtMs, |
| }); |
| } else { |
| files.push({ name, path: filePath, missing: true }); |
| } |
| } |
|
|
| const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME); |
| const primaryMeta = await statFile(primaryMemoryPath); |
| if (primaryMeta) { |
| files.push({ |
| name: DEFAULT_MEMORY_FILENAME, |
| path: primaryMemoryPath, |
| missing: false, |
| size: primaryMeta.size, |
| updatedAtMs: primaryMeta.updatedAtMs, |
| }); |
| } else { |
| const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME); |
| const altMeta = await statFile(altMemoryPath); |
| if (altMeta) { |
| files.push({ |
| name: DEFAULT_MEMORY_ALT_FILENAME, |
| path: altMemoryPath, |
| missing: false, |
| size: altMeta.size, |
| updatedAtMs: altMeta.updatedAtMs, |
| }); |
| } else { |
| files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true }); |
| } |
| } |
|
|
| return files; |
| } |
|
|
| function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadConfig>) { |
| const agentId = normalizeAgentId(agentIdRaw); |
| const allowed = new Set(listAgentIds(cfg)); |
| if (!allowed.has(agentId)) { |
| return null; |
| } |
| return agentId; |
| } |
|
|
| function sanitizeIdentityLine(value: string): string { |
| return value.replace(/\s+/g, " ").trim(); |
| } |
|
|
| function resolveOptionalStringParam(value: unknown): string | undefined { |
| return typeof value === "string" && value.trim() ? value.trim() : undefined; |
| } |
|
|
| async function moveToTrashBestEffort(pathname: string): Promise<void> { |
| if (!pathname) { |
| return; |
| } |
| try { |
| await fs.access(pathname); |
| } catch { |
| return; |
| } |
| try { |
| await movePathToTrash(pathname); |
| } catch { |
| |
| } |
| } |
|
|
| export const agentsHandlers: GatewayRequestHandlers = { |
| "agents.list": ({ params, respond }) => { |
| if (!validateAgentsListParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`, |
| ), |
| ); |
| return; |
| } |
|
|
| const cfg = loadConfig(); |
| const result = listAgentsForGateway(cfg); |
| respond(true, result, undefined); |
| }, |
| "agents.create": async ({ params, respond }) => { |
| if (!validateAgentsCreateParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.create params: ${formatValidationErrors( |
| validateAgentsCreateParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
|
|
| const cfg = loadConfig(); |
| const rawName = String(params.name ?? "").trim(); |
| const agentId = normalizeAgentId(rawName); |
| if (agentId === DEFAULT_AGENT_ID) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" is reserved`), |
| ); |
| return; |
| } |
|
|
| if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" already exists`), |
| ); |
| return; |
| } |
|
|
| const workspaceDir = resolveUserPath(String(params.workspace ?? "").trim()); |
|
|
| |
| |
| let nextConfig = applyAgentConfig(cfg, { |
| agentId, |
| name: rawName, |
| workspace: workspaceDir, |
| }); |
| const agentDir = resolveAgentDir(nextConfig, agentId); |
| nextConfig = applyAgentConfig(nextConfig, { agentId, agentDir }); |
|
|
| |
| |
| const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap); |
| await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); |
| await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true }); |
|
|
| await writeConfigFile(nextConfig); |
|
|
| |
| const safeName = sanitizeIdentityLine(rawName); |
| const emoji = resolveOptionalStringParam(params.emoji); |
| const avatar = resolveOptionalStringParam(params.avatar); |
| const identityPath = path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME); |
| const lines = [ |
| "", |
| `- Name: ${safeName}`, |
| ...(emoji ? [`- Emoji: ${sanitizeIdentityLine(emoji)}`] : []), |
| ...(avatar ? [`- Avatar: ${sanitizeIdentityLine(avatar)}`] : []), |
| "", |
| ]; |
| await fs.appendFile(identityPath, lines.join("\n"), "utf-8"); |
|
|
| respond(true, { ok: true, agentId, name: rawName, workspace: workspaceDir }, undefined); |
| }, |
| "agents.update": async ({ params, respond }) => { |
| if (!validateAgentsUpdateParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.update params: ${formatValidationErrors( |
| validateAgentsUpdateParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
|
|
| const cfg = loadConfig(); |
| const agentId = normalizeAgentId(String(params.agentId ?? "")); |
| if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`), |
| ); |
| return; |
| } |
|
|
| const workspaceDir = |
| typeof params.workspace === "string" && params.workspace.trim() |
| ? resolveUserPath(params.workspace.trim()) |
| : undefined; |
|
|
| const model = resolveOptionalStringParam(params.model); |
| const avatar = resolveOptionalStringParam(params.avatar); |
|
|
| const nextConfig = applyAgentConfig(cfg, { |
| agentId, |
| ...(typeof params.name === "string" && params.name.trim() |
| ? { name: params.name.trim() } |
| : {}), |
| ...(workspaceDir ? { workspace: workspaceDir } : {}), |
| ...(model ? { model } : {}), |
| }); |
|
|
| await writeConfigFile(nextConfig); |
|
|
| if (workspaceDir) { |
| const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap); |
| await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); |
| } |
|
|
| if (avatar) { |
| const workspace = workspaceDir ?? resolveAgentWorkspaceDir(nextConfig, agentId); |
| await fs.mkdir(workspace, { recursive: true }); |
| const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME); |
| await fs.appendFile(identityPath, `\n- Avatar: ${sanitizeIdentityLine(avatar)}\n`, "utf-8"); |
| } |
|
|
| respond(true, { ok: true, agentId }, undefined); |
| }, |
| "agents.delete": async ({ params, respond }) => { |
| if (!validateAgentsDeleteParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.delete params: ${formatValidationErrors( |
| validateAgentsDeleteParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
|
|
| const cfg = loadConfig(); |
| const agentId = normalizeAgentId(String(params.agentId ?? "")); |
| if (agentId === DEFAULT_AGENT_ID) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" cannot be deleted`), |
| ); |
| return; |
| } |
| if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) { |
| respond( |
| false, |
| undefined, |
| errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`), |
| ); |
| return; |
| } |
|
|
| const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true; |
| const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); |
| const agentDir = resolveAgentDir(cfg, agentId); |
| const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); |
|
|
| const result = pruneAgentConfig(cfg, agentId); |
| await writeConfigFile(result.config); |
|
|
| if (deleteFiles) { |
| await Promise.all([ |
| moveToTrashBestEffort(workspaceDir), |
| moveToTrashBestEffort(agentDir), |
| moveToTrashBestEffort(sessionsDir), |
| ]); |
| } |
|
|
| respond(true, { ok: true, agentId, removedBindings: result.removedBindings }, undefined); |
| }, |
| "agents.files.list": async ({ params, respond }) => { |
| if (!validateAgentsFilesListParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.files.list params: ${formatValidationErrors( |
| validateAgentsFilesListParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const cfg = loadConfig(); |
| const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); |
| if (!agentId) { |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); |
| return; |
| } |
| const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); |
| let hideBootstrap = false; |
| try { |
| hideBootstrap = await isWorkspaceOnboardingCompleted(workspaceDir); |
| } catch { |
| |
| } |
| const files = await listAgentFiles(workspaceDir, { hideBootstrap }); |
| respond(true, { agentId, workspace: workspaceDir, files }, undefined); |
| }, |
| "agents.files.get": async ({ params, respond }) => { |
| if (!validateAgentsFilesGetParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.files.get params: ${formatValidationErrors( |
| validateAgentsFilesGetParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond); |
| if (!resolved) { |
| return; |
| } |
| const { agentId, workspaceDir, name } = resolved; |
| const filePath = path.join(workspaceDir, name); |
| const meta = await statFile(filePath); |
| if (!meta) { |
| respond( |
| true, |
| { |
| agentId, |
| workspace: workspaceDir, |
| file: { name, path: filePath, missing: true }, |
| }, |
| undefined, |
| ); |
| return; |
| } |
| const content = await fs.readFile(filePath, "utf-8"); |
| respond( |
| true, |
| { |
| agentId, |
| workspace: workspaceDir, |
| file: { |
| name, |
| path: filePath, |
| missing: false, |
| size: meta.size, |
| updatedAtMs: meta.updatedAtMs, |
| content, |
| }, |
| }, |
| undefined, |
| ); |
| }, |
| "agents.files.set": async ({ params, respond }) => { |
| if (!validateAgentsFilesSetParams(params)) { |
| respond( |
| false, |
| undefined, |
| errorShape( |
| ErrorCodes.INVALID_REQUEST, |
| `invalid agents.files.set params: ${formatValidationErrors( |
| validateAgentsFilesSetParams.errors, |
| )}`, |
| ), |
| ); |
| return; |
| } |
| const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond); |
| if (!resolved) { |
| return; |
| } |
| const { agentId, workspaceDir, name } = resolved; |
| await fs.mkdir(workspaceDir, { recursive: true }); |
| const filePath = path.join(workspaceDir, name); |
| const content = String(params.content ?? ""); |
| await fs.writeFile(filePath, content, "utf-8"); |
| const meta = await statFile(filePath); |
| respond( |
| true, |
| { |
| ok: true, |
| agentId, |
| workspace: workspaceDir, |
| file: { |
| name, |
| path: filePath, |
| missing: false, |
| size: meta?.size, |
| updatedAtMs: meta?.updatedAtMs, |
| content, |
| }, |
| }, |
| undefined, |
| ); |
| }, |
| }; |
|
|