Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import type { GatewayRequestHandlers } 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, | |
| } 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 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]); | |
| 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) { | |
| const files: Array<{ | |
| name: string; | |
| path: string; | |
| missing: boolean; | |
| size?: number; | |
| updatedAtMs?: number; | |
| }> = []; | |
| for (const name of BOOTSTRAP_FILE_NAMES) { | |
| 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 { | |
| // Best-effort: path may already be gone or trash unavailable. | |
| } | |
| } | |
| 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()); | |
| // Resolve agentDir against the config we're about to persist (vs the pre-write config), | |
| // so subsequent resolutions can't disagree about the agent's directory. | |
| let nextConfig = applyAgentConfig(cfg, { | |
| agentId, | |
| name: rawName, | |
| workspace: workspaceDir, | |
| }); | |
| const agentDir = resolveAgentDir(nextConfig, agentId); | |
| nextConfig = applyAgentConfig(nextConfig, { agentId, agentDir }); | |
| // Ensure workspace & transcripts exist BEFORE writing config so a failure | |
| // here does not leave a broken config entry behind. | |
| const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap); | |
| await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap }); | |
| await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true }); | |
| await writeConfigFile(nextConfig); | |
| // Always write Name to IDENTITY.md; optionally include emoji/avatar. | |
| 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); | |
| const files = await listAgentFiles(workspaceDir); | |
| 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 cfg = loadConfig(); | |
| const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); | |
| if (!agentId) { | |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); | |
| return; | |
| } | |
| const name = String(params.name ?? "").trim(); | |
| if (!ALLOWED_FILE_NAMES.has(name)) { | |
| respond( | |
| false, | |
| undefined, | |
| errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`), | |
| ); | |
| return; | |
| } | |
| const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); | |
| 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 cfg = loadConfig(); | |
| const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); | |
| if (!agentId) { | |
| respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); | |
| return; | |
| } | |
| const name = String(params.name ?? "").trim(); | |
| if (!ALLOWED_FILE_NAMES.has(name)) { | |
| respond( | |
| false, | |
| undefined, | |
| errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`), | |
| ); | |
| return; | |
| } | |
| const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); | |
| 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, | |
| ); | |
| }, | |
| }; | |