// Agents gateway methods expose agent listing, config mutation, workspace file
// reads/writes, identity merging, and safe deletion for operator clients.
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString as resolveOptionalStringParam } from "@openclaw/normalization-core/string-coerce";
import {
  GATEWAY_CLIENT_CAPS,
  hasGatewayClientCap,
} from "../../../packages/gateway-protocol/src/client-info.js";
import {
  ErrorCodes,
  errorShape,
  formatValidationErrors,
  validateAgentsCreateParams,
  validateAgentsDeleteParams,
  validateAgentsFilesGetParams,
  validateAgentsFilesListParams,
  validateAgentsFilesSetParams,
  validateAgentsListParams,
  validateAgentsUpdateParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { createAgent } from "../../agents/agent-create.js";
import { findOverlappingWorkspaceAgentIds } from "../../agents/agent-delete-safety.js";
import {
  isPathOwnedByAnotherRegisteredAgent,
  normalizeAgentDirRegistryPath,
  registerResolvedAgentDir,
  resolveRegisteredAgentIdForDir,
  unregisterResolvedAgentDir,
} from "../../agents/agent-dir-registry.js";
import {
  AgentDeletionAuthorityRollbackError,
  AgentDeletionCommitUncertainError,
  beginAgentDeletion,
  claimCompletedAgentDeletion,
} from "../../agents/agent-lifecycle-registry.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
  listAgentIds,
  resolveAgentDir,
  resolveAgentWorkspaceDir,
} from "../../agents/agent-scope.js";
import {
  createAgentIdentityConfig,
  mergeIdentityMarkdownContent,
  normalizeIdentityForFile,
  sanitizeAgentIdentityLine,
} from "../../agents/identity-file.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import {
  prepareLegacyWorkspaceStateReset,
  removeLegacyWorkspaceStateForReset,
} from "../../agents/workspace-legacy-state.js";
import {
  deleteWorkspaceState,
  prepareWorkspaceStateDeletion,
} from "../../agents/workspace-state-store.js";
import {
  DEFAULT_BOOTSTRAP_FILENAME,
  DEFAULT_IDENTITY_FILENAME,
  ensureAgentWorkspace,
  isExpectedAbsentBootstrapFile,
  isWorkspaceSetupCompleted,
  WORKSPACE_BOOTSTRAP_FILENAMES,
} from "../../agents/workspace.js";
import { applyAgentConfig } from "../../commands/agents.config.js";
import {
  readConfigFileSnapshotForWrite,
  withConfigMutationExclusive,
} from "../../config/config.js";
import { purgeAgentSessionStoreEntries } from "../../config/sessions.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import type { IdentityConfig } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withAgentExecApprovalsRemoved } from "../../infra/exec-approvals.js";
import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
import { isPathInside } from "../../infra/path-guards.js";
import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js";
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import {
  readAgentDeletionJournal,
  type AgentDeletionJournalCleanupPath,
} from "../../state/agent-deletion-journal.js";
import { assertNoOpenClawAgentDatabaseLeases } from "../../state/openclaw-agent-db-lease.js";
import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js";
import {
  closeOpenClawAgentDatabaseByPath,
  listOpenClawRegisteredAgentDatabases,
  resolveOpenClawAgentSqlitePath,
} from "../../state/openclaw-agent-db.js";
import { resolveUserPath } from "../../utils.js";
import { listAgentsForGateway } from "../session-utils.js";
import {
  AgentConfigPreconditionError,
  deleteAgentConfigEntry,
  isConfiguredAgent,
  updateAgentConfigEntry,
} from "./agents-config-mutations.js";
import { readPreparedServerMethodModelCatalog } from "./optional-model-catalog.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";

// Derived from the canonical workspace list so retiring a bootstrap file cannot
// leave the Control UI advertising a file the runtime no longer reads.
// IDENTITY.md is excluded: it is a parsed record that `agents.update` rewrites via
// mergeIdentityMarkdownContent, so a second freeform editor would clobber fields
// (Creature/Vibe, unfilled placeholders) that the identity form round-trips.
// It stays writable through agents.files.set for clients that want raw access.
const CORE_FILE_NAMES = WORKSPACE_BOOTSTRAP_FILENAMES.filter(
  (name) => name !== DEFAULT_IDENTITY_FILENAME,
);
const CORE_FILE_NAMES_POST_ONBOARDING = CORE_FILE_NAMES.filter(
  (name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);

const agentsHandlerDeps = {
  root,
  isWorkspaceSetupCompleted,
};

export const testing = {
  setDepsForTests(
    overrides: Partial<{
      root: typeof root;
      isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
    }>,
  ) {
    if (overrides.isWorkspaceSetupCompleted) {
      agentsHandlerDeps.isWorkspaceSetupCompleted = overrides.isWorkspaceSetupCompleted;
    }
    if (overrides.root) {
      agentsHandlerDeps.root = overrides.root;
    }
  },
  resetDepsForTests() {
    agentsHandlerDeps.root = root;
    agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
  },
};

// Writes stay capped to canonical workspace files, and deliberately remain wider
// than the listed core files: IDENTITY.md is not offered as an editor tab but is
// still writable for clients that manage it directly.
const ALLOWED_FILE_NAMES = new Set<string>(WORKSPACE_BOOTSTRAP_FILENAMES);

function resolveAgentWorkspaceFileOrRespondError(
  params: Record<string, unknown>,
  respond: RespondFn,
  cfg: OpenClawConfig,
): {
  cfg: OpenClawConfig;
  agentId: string;
  workspaceDir: string;
  name: string;
} | null {
  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;
};

type WorkspaceRoot = Awaited<ReturnType<typeof root>>;

function isRegularWorkspaceFileStat(stat: {
  isFile: boolean | (() => boolean);
  isSymbolicLink: boolean | (() => boolean);
  nlink: number;
}): boolean {
  const isFile = typeof stat.isFile === "function" ? stat.isFile() : stat.isFile;
  const isSymbolicLink =
    typeof stat.isSymbolicLink === "function" ? stat.isSymbolicLink() : stat.isSymbolicLink;
  // Reject links even after path-root containment so workspace reads cannot follow shared files.
  return isFile && !isSymbolicLink && stat.nlink <= 1;
}

function toWorkspaceFileMeta(
  stat: {
    size: number;
    mtimeMs: number;
  } & Parameters<typeof isRegularWorkspaceFileStat>[0],
): FileMeta | null {
  if (!isRegularWorkspaceFileStat(stat)) {
    return null;
  }
  return {
    size: stat.size,
    updatedAtMs: Math.floor(stat.mtimeMs),
  };
}

async function statWorkspaceFileSafely(
  workspaceRoot: WorkspaceRoot | null,
  workspaceDir: string,
  name: string,
): Promise<FileMeta | null> {
  try {
    const stat = workspaceRoot
      ? await workspaceRoot.stat(name)
      : await fs.lstat(path.join(workspaceDir, name));
    return toWorkspaceFileMeta(stat);
  } catch {
    if (!workspaceRoot) {
      return null;
    }
    try {
      // fs-safe roots can reject fixtures that are still valid regular files for listing metadata.
      const stat = await fs.lstat(path.join(workspaceDir, name));
      return toWorkspaceFileMeta(stat);
    } catch {
      return null;
    }
  }
}

async function openWorkspaceRootSafely(workspaceDir: string): Promise<WorkspaceRoot | null> {
  try {
    return await agentsHandlerDeps.root(workspaceDir);
  } catch {
    return null;
  }
}

async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
  const files: Array<{
    name: string;
    path: string;
    missing: boolean;
    expectedAbsent?: boolean;
    size?: number;
    updatedAtMs?: number;
  }> = [];

  const workspaceRoot = await openWorkspaceRootSafely(workspaceDir);
  if (!workspaceRoot) {
    // Keep the UI shape stable when the workspace path is missing or unsafe.
    const missingNames = options?.hideBootstrap ? CORE_FILE_NAMES_POST_ONBOARDING : CORE_FILE_NAMES;
    return missingNames.map((name) => ({
      name,
      path: path.join(workspaceDir, name),
      missing: true,
      expectedAbsent: isExpectedAbsentBootstrapFile(name),
    }));
  }

  const coreFileNames = options?.hideBootstrap ? CORE_FILE_NAMES_POST_ONBOARDING : CORE_FILE_NAMES;
  for (const name of coreFileNames) {
    const filePath = path.join(workspaceDir, name);
    const meta = await statWorkspaceFileSafely(workspaceRoot, workspaceDir, name);
    if (meta) {
      files.push({
        name,
        path: filePath,
        missing: false,
        size: meta.size,
        updatedAtMs: meta.updatedAtMs,
      });
    } else {
      files.push({
        name,
        path: filePath,
        missing: true,
        expectedAbsent: isExpectedAbsentBootstrapFile(name),
      });
    }
  }

  return files;
}

function resolveAgentIdOrError(agentIdRaw: string, cfg: OpenClawConfig) {
  const agentId = normalizeAgentId(agentIdRaw);
  const allowed = new Set(listAgentIds(cfg));
  if (!allowed.has(agentId)) {
    return null;
  }
  return agentId;
}

function respondInvalidMethodParams(
  respond: RespondFn,
  method: string,
  errors: Parameters<typeof formatValidationErrors>[0],
): void {
  respond(
    false,
    undefined,
    errorShape(
      ErrorCodes.INVALID_REQUEST,
      `invalid ${method} params: ${formatValidationErrors(errors)}`,
    ),
  );
}

function respondAgentNotFound(respond: RespondFn, agentId: string): void {
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`));
}

type AgentDeleteRemovedPath = {
  path: string;
  method: "trash" | "missing";
};

type AgentDeleteFailedPath = {
  path: string;
  reason: string;
};

type AgentDeletePathOutcome =
  | { removed: AgentDeleteRemovedPath }
  | { skipped: AgentDeleteFailedPath }
  | { failed: AgentDeleteFailedPath };

class AgentCleanupIdentityMismatchError extends Error {}

function cleanupFailure(pathname: string, error: unknown): AgentDeletePathOutcome {
  const reason = error instanceof Error && error.message ? error.message : String(error);
  return { failed: { path: pathname, reason: reason || "unknown error" } };
}

function cleanupPathIdentity(stat: { dev?: number | bigint; ino?: number | bigint } | undefined) {
  if (
    (typeof stat?.dev !== "number" && typeof stat?.dev !== "bigint") ||
    (typeof stat.ino !== "number" && typeof stat.ino !== "bigint")
  ) {
    return null;
  }
  const dev = Number(stat.dev);
  const ino = Number(stat.ino);
  if (!Number.isSafeInteger(dev) || !Number.isSafeInteger(ino)) {
    throw new Error("cleanup path identity exceeds the safe integer range");
  }
  return { dev, ino };
}

async function statAgentCleanupPath(cleanupPath: AgentDeleteCleanupPath) {
  const parentPath = cleanupPath.parentPath;
  const parentRoot = await agentsHandlerDeps.root(parentPath, {
    hardlinks: "reject",
    symlinks: "reject",
  });
  if (path.resolve(parentRoot.rootReal) !== parentPath) {
    throw new FsSafeError("path-mismatch", "cleanup path parent changed before deletion");
  }
  const stat = await parentRoot.stat(path.basename(cleanupPath.trashPath));
  const isSymlink = stat.isSymbolicLink;
  if (isSymlink !== (cleanupPath.kind === "symlink")) {
    throw new AgentCleanupIdentityMismatchError(
      `cleanup path changed from ${cleanupPath.kind} before deletion`,
    );
  }
  if (stat.isFile && stat.nlink > 1) {
    throw new AgentCleanupIdentityMismatchError("hardlinked cleanup replacement preserved");
  }
  const identity = cleanupPathIdentity(stat);
  if (cleanupPath.preparedIdentity === null) {
    if (identity !== null) {
      throw new AgentCleanupIdentityMismatchError(
        "cleanup path appeared after deletion preparation",
      );
    }
  } else if (
    identity === null ||
    identity.dev !== cleanupPath.preparedIdentity.dev ||
    identity.ino !== cleanupPath.preparedIdentity.ino
  ) {
    throw new AgentCleanupIdentityMismatchError("cleanup path identity changed before deletion");
  }
}

function isMissingCleanupPathError(error: unknown): boolean {
  return (
    (error instanceof FsSafeError && error.code === "not-found") ||
    (error as NodeJS.ErrnoException).code === "ENOENT"
  );
}

async function removeAgentPath(
  cleanupPath: AgentDeleteCleanupPath,
): Promise<AgentDeletePathOutcome> {
  const pathname = cleanupPath.path;
  const trashPath = cleanupPath.trashPath;
  try {
    await statAgentCleanupPath(cleanupPath);
  } catch (error) {
    if (error instanceof AgentCleanupIdentityMismatchError) {
      return { skipped: { path: pathname, reason: error.message } };
    }
    return isMissingCleanupPathError(error)
      ? { removed: { path: pathname, method: "missing" } }
      : cleanupFailure(pathname, error);
  }
  try {
    // fs-safe pins traversal and identity for validation; Trash has no fd-relative move API, so
    // replacement after this check and before its rename is the accepted residual race bound.
    await movePathToTrash(trashPath);
    return { removed: { path: pathname, method: "trash" } };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
      return cleanupFailure(pathname, error);
    }
    try {
      await statAgentCleanupPath(cleanupPath);
      return cleanupFailure(pathname, error);
    } catch (statError) {
      return isMissingCleanupPathError(statError)
        ? { removed: { path: pathname, method: "missing" } }
        : cleanupFailure(pathname, statError);
    }
  }
}

type AgentDeleteCleanupPath = {
  path: string;
  parentPath: string;
  canonicalPath: string;
  trashPath: string;
  trashCoversDescendants: boolean;
  kind: "target" | "symlink";
  preparedIdentity: { dev: number; ino: number } | null;
  done: boolean;
  note?: string;
  preparationError?: unknown;
  sourcePaths: string[];
};

async function resolveAgentDeleteCleanupTarget(pathname: string): Promise<string> {
  let candidate = path.resolve(pathname);
  const missingSuffix: string[] = [];
  while (true) {
    try {
      return path.resolve(await fs.realpath(candidate), ...missingSuffix);
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
        throw error;
      }
      let candidateStat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
      try {
        candidateStat = await fs.lstat(candidate);
      } catch (statError) {
        if ((statError as NodeJS.ErrnoException).code !== "ENOENT") {
          throw statError;
        }
      }
      if (candidateStat?.isSymbolicLink()) {
        const linkTarget = await fs.readlink(candidate);
        const resolvedLinkTarget = await resolveAgentDeleteCleanupTarget(
          path.isAbsolute(linkTarget)
            ? linkTarget
            : path.resolve(path.dirname(candidate), linkTarget),
        );
        return path.resolve(resolvedLinkTarget, ...missingSuffix);
      }
      const parent = path.dirname(candidate);
      if (parent === candidate) {
        throw error;
      }
      missingSuffix.unshift(path.basename(candidate));
      candidate = parent;
    }
  }
}

async function prepareAgentDeleteCleanupPaths(
  paths: readonly string[],
  persistedPaths: readonly AgentDeletionJournalCleanupPath[] = [],
): Promise<AgentDeleteCleanupPath[]> {
  const uniquePaths = new Map<string, AgentDeleteCleanupPath>();
  const addPath = (candidate: AgentDeleteCleanupPath) => {
    const existing = uniquePaths.get(candidate.trashPath);
    if (!existing) {
      uniquePaths.set(candidate.trashPath, candidate);
      return;
    }
    existing.sourcePaths = [...new Set([...existing.sourcePaths, ...candidate.sourcePaths])];
    existing.done ||= candidate.done;
    existing.note ??= candidate.note;
    existing.preparationError ??= candidate.preparationError;
    if (candidate.kind === "target") {
      existing.kind = "target";
      existing.canonicalPath = candidate.canonicalPath;
      existing.parentPath = candidate.parentPath;
      existing.trashCoversDescendants ||= candidate.trashCoversDescendants;
    }
  };
  if (persistedPaths.length > 0) {
    for (const persistedPath of persistedPaths) {
      const journalPath = path.resolve(persistedPath.path);
      const trashPath = path.resolve(persistedPath.canonicalPath);
      addPath({
        path: journalPath,
        parentPath: path.resolve(persistedPath.parentPath),
        canonicalPath: normalizeAgentDirRegistryPath(trashPath),
        trashPath,
        trashCoversDescendants: persistedPath.coversDescendants,
        kind: persistedPath.kind,
        preparedIdentity:
          persistedPath.dev === null || persistedPath.ino === null
            ? null
            : { dev: persistedPath.dev, ino: persistedPath.ino },
        done: persistedPath.done,
        note: persistedPath.note,
        sourcePaths: persistedPath.sourcePaths.map((sourcePath) => path.resolve(sourcePath)),
      });
    }
  }
  for (const pathname of paths) {
    const sourcePath = path.resolve(pathname);
    let sourceParentPath = path.dirname(sourcePath);
    let resolvedPath = sourcePath;
    let preparationError: unknown;
    try {
      resolvedPath = await resolveAgentDeleteCleanupTarget(pathname);
      sourceParentPath = await resolveAgentDeleteCleanupTarget(path.dirname(sourcePath));
    } catch (error) {
      preparationError = error;
    }
    let sourceStat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
    try {
      sourceStat = await fs.lstat(pathname);
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
        preparationError ??= error;
      }
    }
    let targetStat = sourceStat;
    if (resolvedPath !== sourcePath) {
      try {
        targetStat = await fs.lstat(resolvedPath);
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
          preparationError ??= error;
        }
        targetStat = undefined;
      }
    }
    const canonicalPath = normalizeAgentDirRegistryPath(resolvedPath);
    let trashCoversDescendants = false;
    if (targetStat) {
      trashCoversDescendants = !targetStat.isSymbolicLink();
    }
    addPath({
      path: resolvedPath,
      parentPath: path.dirname(resolvedPath),
      canonicalPath,
      trashPath: resolvedPath,
      trashCoversDescendants,
      kind: "target",
      preparedIdentity: cleanupPathIdentity(targetStat),
      done: false,
      preparationError,
      sourcePaths: [sourcePath],
    });
    if (sourceStat?.isSymbolicLink() && sourcePath !== resolvedPath) {
      addPath({
        path: sourcePath,
        parentPath: sourceParentPath,
        canonicalPath,
        trashPath: path.join(sourceParentPath, path.basename(sourcePath)),
        trashCoversDescendants: false,
        kind: "symlink",
        preparedIdentity: cleanupPathIdentity(sourceStat),
        done: false,
        sourcePaths: [sourcePath],
      });
    }
  }
  const depth = (pathname: string) =>
    path.relative(path.parse(pathname).root, pathname).split(path.sep).filter(Boolean).length;
  const cleanupDepth = (cleanupPath: AgentDeleteCleanupPath) =>
    Math.max(
      depth(cleanupPath.canonicalPath),
      depth(cleanupPath.trashPath),
      ...cleanupPath.sourcePaths.map(depth),
    );
  const compareFallback = (left: AgentDeleteCleanupPath, right: AgentDeleteCleanupPath) => {
    if (left.kind !== right.kind) {
      return left.kind === "target" ? -1 : 1;
    }
    const depthDifference = cleanupDepth(right) - cleanupDepth(left);
    if (depthDifference !== 0) {
      return depthDifference;
    }
    const trashDepth = depth(right.trashPath) - depth(left.trashPath);
    return trashDepth || left.trashPath.localeCompare(right.trashPath);
  };
  const mustPrecede = (left: AgentDeleteCleanupPath, right: AgentDeleteCleanupPath) => {
    if (left.kind !== right.kind) {
      return left.kind === "target";
    }
    if (isPathInside(right.trashPath, left.trashPath)) {
      return true;
    }
    if (isPathInside(left.trashPath, right.trashPath)) {
      return false;
    }
    const rightRoots = [right.trashPath, ...right.sourcePaths];
    return left.sourcePaths.some((leftSource) =>
      rightRoots.some((rightRoot) => isPathInside(rightRoot, leftSource)),
    );
  };
  const remaining = [...uniquePaths.values()].toSorted(compareFallback);
  const ordered: AgentDeleteCleanupPath[] = [];
  // Snapshot real targets and clean every physical or lexical descendant first; moving an
  // ancestor symlink would otherwise hide surviving child data and let recovery finalize.
  while (remaining.length > 0) {
    const nextIndex = remaining.findIndex((candidate, candidateIndex) =>
      remaining.every(
        (other, otherIndex) => otherIndex === candidateIndex || !mustPrecede(other, candidate),
      ),
    );
    ordered.push(...remaining.splice(Math.max(0, nextIndex), 1));
  }
  return ordered;
}

function cleanupPathCovers(
  cleanupPath: AgentDeleteCleanupPath,
  targetPath: string,
  canonicalTargetPath: string,
): boolean {
  const trashTargetPath = path.resolve(targetPath);
  return (
    cleanupPath.sourcePaths.includes(trashTargetPath) ||
    cleanupPath.trashPath === trashTargetPath ||
    (cleanupPath.trashCoversDescendants &&
      (cleanupPath.kind === "target" || isPathInside(cleanupPath.trashPath, trashTargetPath)) &&
      isPathInside(cleanupPath.canonicalPath, canonicalTargetPath))
  );
}

type AgentDeleteDatabasePlan = {
  paths: string[];
  registrationPaths: string[];
  fileGroups: string[][];
  relocatedFileGroups: string[][];
};

function resolveSurvivingDatabaseFilePaths(
  registeredDatabases: ReturnType<typeof listOpenClawRegisteredAgentDatabases>,
  agentId: string,
): string[] {
  return [
    ...new Set(
      registeredDatabases
        .filter((entry) => normalizeAgentId(entry.agentId) !== agentId)
        .flatMap((entry) => resolveSqliteDatabaseFilePaths(entry.path))
        .map((pathname) => normalizeAgentDirRegistryPath(pathname)),
    ),
  ];
}

function isPathOwnedBySurvivingAgent(
  cfg: OpenClawConfig,
  agentId: string,
  pathname: string,
  survivingDatabaseFilePaths: readonly string[] = [],
): boolean {
  const canonicalPath = normalizeAgentDirRegistryPath(pathname);
  return (
    isPathOwnedByAnotherRegisteredAgent({ agentId, pathname }) ||
    findOverlappingWorkspaceAgentIds(cfg, agentId, pathname).length > 0 ||
    survivingDatabaseFilePaths.some(
      (databasePath) =>
        databasePath === canonicalPath ||
        isPathInside(databasePath, canonicalPath) ||
        isPathInside(canonicalPath, databasePath),
    )
  );
}

function prepareAgentDeleteDatabases(
  cfg: OpenClawConfig,
  agentId: string,
  agentDir: string,
): AgentDeleteDatabasePlan {
  const registeredDatabases = listOpenClawRegisteredAgentDatabases();
  const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
    registeredDatabases,
    agentId,
  );
  const registeredDatabasePaths = new Set([
    resolveOpenClawAgentSqlitePath({
      agentId,
      path: path.join(agentDir, "openclaw-agent.sqlite"),
    }),
    ...registeredDatabases
      .filter((entry) => normalizeAgentId(entry.agentId) === agentId)
      .map((entry) => entry.path),
  ]);
  const databasePaths = [...registeredDatabasePaths].filter((pathname) =>
    resolveSqliteDatabaseFilePaths(pathname).every(
      (filePath) =>
        !isPathOwnedBySurvivingAgent(cfg, agentId, filePath, survivingDatabaseFilePaths),
    ),
  );
  for (const databasePath of databasePaths) {
    closeOpenClawAgentDatabaseByPath(databasePath);
  }
  assertNoOpenClawAgentDatabaseLeases(agentId);
  const fileGroups = databasePaths.map(resolveSqliteDatabaseFilePaths);
  const relocatedFileGroups = fileGroups.filter((fileGroup) => {
    const relative = path.relative(agentDir, fileGroup[0] ?? agentDir);
    return relative.startsWith("..") || path.isAbsolute(relative);
  });
  return {
    paths: databasePaths,
    registrationPaths: [...registeredDatabasePaths],
    fileGroups,
    relocatedFileGroups,
  };
}

function unregisterAgentDeleteDatabases(agentId: string, databasePaths: string[]): void {
  for (const databasePath of databasePaths) {
    unregisterOpenClawAgentDatabase({ agentId, path: databasePath });
  }
}

function prepareJournaledAgentDirOwnership(
  cfg: OpenClawConfig,
  agentId: string,
  agentDir: string,
): void {
  for (const configuredAgentId of listAgentIds(cfg)) {
    resolveAgentDir(cfg, configuredAgentId);
  }
  const registeredOwner = resolveRegisteredAgentIdForDir(agentDir);
  if (registeredOwner !== undefined) {
    return;
  }
  // The durable journal retains ownership across restarts after the roster entry is gone.
  registerResolvedAgentDir({ agentId, agentDir });
}

function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
  respond(
    false,
    undefined,
    errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
  );
}

function respondWorkspaceFileMissing(params: {
  respond: RespondFn;
  agentId: string;
  workspaceDir: string;
  name: string;
  filePath: string;
}): void {
  params.respond(
    true,
    {
      agentId: params.agentId,
      workspace: params.workspaceDir,
      // Clients merge this entry over the listed one, so it must carry the same
      // absence classification or a picked optional file re-renders as a fault.
      file: {
        name: params.name,
        path: params.filePath,
        missing: true,
        expectedAbsent: isExpectedAbsentBootstrapFile(params.name),
      },
    },
    undefined,
  );
}

async function writeWorkspaceFileOrRespond(params: {
  respond: RespondFn;
  workspaceDir: string;
  name: string;
  content: string;
}): Promise<boolean> {
  await fs.mkdir(params.workspaceDir, { recursive: true });
  try {
    const workspaceRoot = await agentsHandlerDeps.root(params.workspaceDir);
    await workspaceRoot.write(params.name, params.content, { encoding: "utf8" });
  } catch (err) {
    if (err instanceof FsSafeError) {
      respondWorkspaceFileUnsafe(params.respond, params.name);
      return false;
    }
    throw err;
  }
  return true;
}

async function readWorkspaceFileContent(
  workspaceDir: string,
  name: string,
): Promise<string | undefined> {
  try {
    const workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
    const safeRead = await workspaceRoot.read(name, {
      hardlinks: "reject",
      nonBlockingRead: true,
    });
    return safeRead.buffer.toString("utf-8");
  } catch (err) {
    if (err instanceof FsSafeError && err.code === "not-found") {
      return undefined;
    }
    throw err;
  }
}

async function buildIdentityMarkdownForWrite(params: {
  workspaceDir: string;
  identity: IdentityConfig;
  fallbackWorkspaceDir?: string;
  preferFallbackWorkspaceContent?: boolean;
}): Promise<string> {
  let baseContent: string | undefined;
  if (params.preferFallbackWorkspaceContent && params.fallbackWorkspaceDir) {
    // Workspace moves may create a blank identity file; merge into the previous user-edited file.
    baseContent = await readWorkspaceFileContent(
      params.fallbackWorkspaceDir,
      DEFAULT_IDENTITY_FILENAME,
    );
    if (baseContent === undefined) {
      baseContent = await readWorkspaceFileContent(params.workspaceDir, DEFAULT_IDENTITY_FILENAME);
    }
  } else {
    baseContent = await readWorkspaceFileContent(params.workspaceDir, DEFAULT_IDENTITY_FILENAME);
    if (baseContent === undefined && params.fallbackWorkspaceDir) {
      baseContent = await readWorkspaceFileContent(
        params.fallbackWorkspaceDir,
        DEFAULT_IDENTITY_FILENAME,
      );
    }
  }

  return mergeIdentityMarkdownContent(baseContent, params.identity);
}

async function buildIdentityMarkdownOrRespondUnsafe(params: {
  respond: RespondFn;
  workspaceDir: string;
  identity: IdentityConfig;
  fallbackWorkspaceDir?: string;
  preferFallbackWorkspaceContent?: boolean;
}): Promise<string | null> {
  try {
    return await buildIdentityMarkdownForWrite(params);
  } catch (err) {
    if (err instanceof FsSafeError) {
      respondWorkspaceFileUnsafe(params.respond, DEFAULT_IDENTITY_FILENAME);
      return null;
    }
    throw err;
  }
}

export const agentsHandlers: GatewayRequestHandlers = {
  "agents.list": async ({ params, respond, context, client }) => {
    if (!validateAgentsListParams(params)) {
      respondInvalidMethodParams(respond, "agents.list", validateAgentsListParams.errors);
      return;
    }

    const cfg = context.getRuntimeConfig();
    const modelCatalog = await readPreparedServerMethodModelCatalog(context);
    const result = listAgentsForGateway(cfg, modelCatalog, {
      includeSystem: hasGatewayClientCap(client?.connect.caps, GATEWAY_CLIENT_CAPS.AGENT_KIND),
    });
    respond(true, result, undefined);
  },
  "agents.create": async ({ params, respond }) => {
    if (!validateAgentsCreateParams(params)) {
      respondInvalidMethodParams(respond, "agents.create", validateAgentsCreateParams.errors);
      return;
    }

    const result = await createAgent({
      name: params.name,
      workspace: params.workspace,
      model: params.model,
      emoji: params.emoji,
      avatar: params.avatar,
    });
    if (result.status === "error") {
      respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, result.message));
      return;
    }
    respond(
      true,
      {
        ok: true,
        agentId: result.agentId,
        name: result.name,
        workspace: result.workspace,
        ...(result.model ? { model: result.model } : {}),
      },
      undefined,
    );
  },
  "agents.update": async ({ params, respond, context }) => {
    if (!validateAgentsUpdateParams(params)) {
      respondInvalidMethodParams(respond, "agents.update", validateAgentsUpdateParams.errors);
      return;
    }

    const cfg = context.getRuntimeConfig();
    const agentId = normalizeAgentId(params.agentId);
    if (!isConfiguredAgent(cfg, agentId)) {
      respondAgentNotFound(respond, agentId);
      return;
    }

    const workspaceDir =
      typeof params.workspace === "string" && params.workspace.trim()
        ? resolveUserPath(params.workspace.trim())
        : undefined;

    const model = params.model === null ? null : resolveOptionalStringParam(params.model);

    const safeName =
      typeof params.name === "string" && params.name.trim()
        ? sanitizeAgentIdentityLine(params.name.trim())
        : undefined;

    const identity = createAgentIdentityConfig({
      name: safeName,
      emoji: params.emoji,
      avatar: params.avatar,
    });
    const hasIdentityFields = Boolean(identity);

    const agentConfigUpdate: Parameters<typeof updateAgentConfigEntry>[0] = {
      agentId,
      ...(safeName ? { name: safeName } : {}),
      ...(workspaceDir ? { workspace: workspaceDir } : {}),
      ...(model !== undefined ? { model } : {}),
      ...(identity ? { identity } : {}),
    };
    const nextConfig = applyAgentConfig(cfg, agentConfigUpdate);

    let ensuredWorkspace: Awaited<ReturnType<typeof ensureAgentWorkspace>> | undefined;
    if (workspaceDir) {
      const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
      ensuredWorkspace = await ensureAgentWorkspace({
        dir: workspaceDir,
        ensureBootstrapFiles: !skipBootstrap,
        skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
      });
    }

    const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId));
    if (persistedIdentity && (workspaceDir || hasIdentityFields)) {
      const identityWorkspaceDir = resolveAgentWorkspaceDir(nextConfig, agentId);
      const previousWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
      const fallbackWorkspaceDir =
        workspaceDir && identityWorkspaceDir !== previousWorkspaceDir
          ? previousWorkspaceDir
          : undefined;
      const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
        respond,
        workspaceDir: identityWorkspaceDir,
        identity: persistedIdentity,
        fallbackWorkspaceDir,
        preferFallbackWorkspaceContent:
          Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true,
      });
      if (identityContent === null) {
        return;
      }
      if (
        !(await writeWorkspaceFileOrRespond({
          respond,
          workspaceDir: identityWorkspaceDir,
          name: DEFAULT_IDENTITY_FILENAME,
          content: identityContent,
        }))
      ) {
        return;
      }
    }

    try {
      await updateAgentConfigEntry(agentConfigUpdate);
    } catch (error) {
      if (error instanceof AgentConfigPreconditionError) {
        respondAgentNotFound(respond, agentId);
        return;
      }
      throw error;
    }

    respond(true, { ok: true, agentId }, undefined);
  },
  "agents.delete": async ({ params, respond, context }) => {
    if (!validateAgentsDeleteParams(params)) {
      respondInvalidMethodParams(respond, "agents.delete", validateAgentsDeleteParams.errors);
      return;
    }

    const cfg = context.getRuntimeConfig();
    const agentId = normalizeAgentId(params.agentId);
    // agents/main/agent also owns the shipped shared legacy auth store.
    // Keep main undeletable until named agents make auth-store ownership explicit.
    if (agentId === LEGACY_IMPLICIT_AGENT_ID) {
      respond(
        false,
        undefined,
        errorShape(ErrorCodes.INVALID_REQUEST, `"${LEGACY_IMPLICIT_AGENT_ID}" cannot be deleted`),
      );
      return;
    }
    const existingJournal = readAgentDeletionJournal(agentId);
    if (
      !isConfiguredAgent(cfg, agentId) &&
      (!existingJournal || existingJournal.cleanupCompleted)
    ) {
      respondAgentNotFound(respond, agentId);
      return;
    }
    if (agentId === resolveDefaultAgentId(cfg)) {
      respond(
        false,
        undefined,
        errorShape(
          ErrorCodes.INVALID_REQUEST,
          `Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`,
        ),
      );
      return;
    }

    const requestedDeleteFiles =
      typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
    try {
      const result = await withConfigMutationExclusive(async (lockedConfig) => {
        let lockedJournal = readAgentDeletionJournal(agentId);
        const configured = isConfiguredAgent(lockedConfig, agentId);
        if (!configured && (!lockedJournal || lockedJournal.cleanupCompleted)) {
          throw new AgentConfigPreconditionError(`agent "${agentId}" not found`);
        }
        if (agentId === resolveDefaultAgentId(lockedConfig)) {
          throw new AgentConfigPreconditionError(
            `agent "${agentId}" is the default; reassign default first`,
          );
        }
        if (configured && lockedJournal?.cleanupCompleted) {
          const claimed = claimCompletedAgentDeletion(agentId, lockedJournal.operationId);
          const remainingJournal = readAgentDeletionJournal(agentId);
          if (!claimed && remainingJournal) {
            throw new Error(`agent "${agentId}" deletion tombstone changed before fresh deletion`);
          }
          lockedJournal = undefined;
        }
        const deleteFiles = lockedJournal?.deleteFiles ?? requestedDeleteFiles;
        const deletion = beginAgentDeletion(
          lockedJournal ?? {
            agentId,
            agentDir: resolveAgentDir(lockedConfig, agentId),
            workspaceDir: resolveAgentWorkspaceDir(lockedConfig, agentId),
            sessionsDir: resolveSessionTranscriptsDirForAgent(agentId),
            deleteFiles,
          },
        );
        const journal = deletion.entry;
        let rosterCommitted = !configured;
        let committed: Awaited<ReturnType<typeof deleteAgentConfigEntry>> | undefined;
        let databasePlan: AgentDeleteDatabasePlan | undefined;
        try {
          prepareJournaledAgentDirOwnership(lockedConfig, agentId, journal.agentDir);
          databasePlan = prepareAgentDeleteDatabases(lockedConfig, agentId, journal.agentDir);
          deletion.fenceDatabasePaths([
            ...journal.databasePaths,
            ...databasePlan.fileGroups.flat(),
          ]);
          if (deleteFiles) {
            const fencedSourcePaths = new Set(
              journal.cleanupPaths.flatMap((cleanupPath) =>
                cleanupPath.sourcePaths.map((sourcePath) => path.resolve(sourcePath)),
              ),
            );
            const unfencedSourcePaths = [
              journal.workspaceDir,
              journal.agentDir,
              journal.sessionsDir,
              ...journal.databasePaths,
            ].filter((sourcePath) => !fencedSourcePaths.has(path.resolve(sourcePath)));
            if (unfencedSourcePaths.length > 0) {
              const unfencedSourcePathSet = new Set(
                unfencedSourcePaths.map((sourcePath) => path.resolve(sourcePath)),
              );
              const cleanupPlan = await prepareAgentDeleteCleanupPaths(
                unfencedSourcePaths,
                journal.cleanupPaths,
              );
              const unresolvedPath = cleanupPlan.find(
                (cleanupPath) =>
                  cleanupPath.preparationError !== undefined &&
                  cleanupPath.sourcePaths.some((sourcePath) =>
                    unfencedSourcePathSet.has(path.resolve(sourcePath)),
                  ),
              );
              if (unresolvedPath) {
                throw unresolvedPath.preparationError;
              }
              deletion.fenceCleanupPaths(
                cleanupPlan.map(
                  ({
                    path: cleanupPath,
                    trashPath,
                    parentPath,
                    kind,
                    preparedIdentity,
                    trashCoversDescendants,
                    done,
                    note,
                    sourcePaths,
                  }) => {
                    const journalPath: AgentDeletionJournalCleanupPath = {
                      path: cleanupPath,
                      canonicalPath: trashPath,
                      parentPath,
                      kind,
                      sourcePaths,
                      dev: preparedIdentity?.dev ?? null,
                      ino: preparedIdentity?.ino ?? null,
                      coversDescendants: trashCoversDescendants,
                      done,
                    };
                    if (note) {
                      journalPath.note = note;
                    }
                    return journalPath;
                  },
                ),
              );
            }
          }
          await context.cron.removeAgentJobsTransactional(
            agentId,
            async () =>
              await withAgentExecApprovalsRemoved(agentId, async () => {
                if (!rosterCommitted) {
                  try {
                    committed = await deleteAgentConfigEntry({ agentId });
                  } catch (error) {
                    try {
                      const persisted = await readConfigFileSnapshotForWrite();
                      if (!isConfiguredAgent(persisted.snapshot.sourceConfig, agentId)) {
                        rosterCommitted = true;
                        throw new AgentDeletionCommitUncertainError(error);
                      }
                    } catch (readError) {
                      if (readError instanceof AgentDeletionCommitUncertainError) {
                        throw readError;
                      }
                      throw new AgentDeletionCommitUncertainError(error);
                    }
                    throw error;
                  }
                  if (!committed.result) {
                    rosterCommitted = !isConfiguredAgent(committed.nextConfig, agentId);
                    const missingResultError = new Error(
                      "agent delete config mutation did not return its target",
                    );
                    if (rosterCommitted) {
                      throw new AgentDeletionCommitUncertainError(missingResultError);
                    }
                    throw missingResultError;
                  }
                  rosterCommitted = true;
                }
              }),
          );
          deletion.commit();
        } catch (error) {
          let canReleaseFence =
            !rosterCommitted &&
            !lockedJournal &&
            !(error instanceof AgentDeletionAuthorityRollbackError) &&
            !(error instanceof AgentDeletionCommitUncertainError);
          if (canReleaseFence) {
            try {
              const persisted = await readConfigFileSnapshotForWrite();
              canReleaseFence = isConfiguredAgent(persisted.snapshot.sourceConfig, agentId);
            } catch {
              canReleaseFence = false;
            }
          }
          if (canReleaseFence) {
            deletion.rollback();
          }
          throw error;
        }

        const deleteResult = committed?.result ?? {
          agentDir: journal.agentDir,
          workspaceDir: journal.workspaceDir,
          sessionsDir: journal.sessionsDir,
          removedBindings: 0,
        };
        const nextConfig = committed?.nextConfig ?? lockedConfig;

        // A journaled path is trash-eligible only while registry ownership still points at the
        // deleted agent; recovery must not consume a path claimed by a surviving agent.
        const agentDirRegistryPath = normalizeAgentDirRegistryPath(deleteResult.agentDir);
        await purgeAgentSessionStoreEntries(lockedConfig, agentId);

        const removed: AgentDeleteRemovedPath[] = [];
        const failed: AgentDeleteFailedPath[] = [];

        if (deleteFiles) {
          const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
            listOpenClawRegisteredAgentDatabases(),
            agentId,
          );
          const workspaceTrashEligible = !isPathOwnedBySurvivingAgent(
            nextConfig,
            agentId,
            deleteResult.workspaceDir,
            survivingDatabaseFilePaths,
          );
          // The config mutation lock and durable journal fence block new roster and database
          // claims across this final ownership recheck and the filesystem cleanup below.
          const agentDirTrashEligible =
            resolveRegisteredAgentIdForDir(deleteResult.agentDir) === agentId &&
            !isPathOwnedBySurvivingAgent(
              nextConfig,
              agentId,
              deleteResult.agentDir,
              survivingDatabaseFilePaths,
            );
          const sessionsDirTrashEligible = !isPathOwnedBySurvivingAgent(
            nextConfig,
            agentId,
            deleteResult.sessionsDir,
            survivingDatabaseFilePaths,
          );
          const databaseFilePaths = [
            ...(agentDirTrashEligible
              ? (databasePlan?.relocatedFileGroups ?? [])
              : (databasePlan?.fileGroups ?? [])
            ).flat(),
            ...journal.databasePaths,
          ].filter(
            (pathname) =>
              !isPathOwnedBySurvivingAgent(
                nextConfig,
                agentId,
                pathname,
                survivingDatabaseFilePaths,
              ),
          );
          const eligibleSourcePaths = new Set(
            [
              ...(workspaceTrashEligible ? [deleteResult.workspaceDir] : []),
              ...(agentDirTrashEligible ? [deleteResult.agentDir] : []),
              ...(sessionsDirTrashEligible ? [deleteResult.sessionsDir] : []),
              ...databaseFilePaths,
            ].map((sourcePath) => path.resolve(sourcePath)),
          );
          const cleanupPaths = (
            await prepareAgentDeleteCleanupPaths([], journal.cleanupPaths)
          ).filter(
            (cleanupPath) =>
              cleanupPath.sourcePaths.some((sourcePath) => eligibleSourcePaths.has(sourcePath)) &&
              (agentDirTrashEligible ||
                !cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath)),
          );
          const workspaceCanonicalPath = normalizeAgentDirRegistryPath(deleteResult.workspaceDir);
          const workspaceCleanupPaths = cleanupPaths.filter((cleanupPath) =>
            cleanupPathCovers(cleanupPath, deleteResult.workspaceDir, workspaceCanonicalPath),
          );
          const legacyPlan =
            workspaceCleanupPaths.length > 0
              ? prepareLegacyWorkspaceStateReset(deleteResult.workspaceDir)
              : undefined;
          const statePlan =
            workspaceCleanupPaths.length > 0
              ? prepareWorkspaceStateDeletion(deleteResult.workspaceDir)
              : undefined;
          const outcomes: Array<{
            cleanupPath: AgentDeleteCleanupPath;
            outcome: AgentDeletePathOutcome;
          }> = [];
          const completedCleanupPaths = new Set(
            cleanupPaths.filter((cleanupPath) => cleanupPath.done),
          );
          const markCleanupPathDone = (cleanupPath: AgentDeleteCleanupPath, note?: string) => {
            const canonicalPath = path.resolve(cleanupPath.trashPath);
            deletion.fenceCleanupPaths(
              journal.cleanupPaths.map((entry) => {
                if (
                  path.resolve(entry.canonicalPath) !== canonicalPath ||
                  entry.kind !== cleanupPath.kind
                ) {
                  return entry;
                }
                const updated = Object.assign({}, entry, { done: true });
                if (note) {
                  updated.note = note;
                }
                return updated;
              }),
            );
            cleanupPath.done = true;
            cleanupPath.note = note;
            completedCleanupPaths.add(cleanupPath);
          };
          const protectedCleanupPaths: Array<{
            cleanupPath: AgentDeleteCleanupPath;
            protectAliases: boolean;
            terminal: boolean;
            note?: string;
          }> = [];
          for (const cleanupPath of cleanupPaths) {
            if (cleanupPath.done) {
              let replacementPresent = true;
              let note =
                cleanupPath.note ?? "completed cleanup path is occupied; replacement preserved";
              try {
                await statAgentCleanupPath(cleanupPath);
              } catch (error) {
                if (isMissingCleanupPathError(error)) {
                  replacementPresent = false;
                } else if (!(error instanceof AgentCleanupIdentityMismatchError)) {
                  note = "completed cleanup path could not be verified; replacement preserved";
                }
              }
              if (replacementPresent) {
                markCleanupPathDone(cleanupPath, note);
                protectedCleanupPaths.push({
                  cleanupPath,
                  protectAliases: true,
                  terminal: true,
                  note,
                });
              }
              continue;
            }
            const refreshedDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
              listOpenClawRegisteredAgentDatabases(),
              agentId,
            );
            const blockingProtection = protectedCleanupPaths.find(
              ({ cleanupPath: protectedPath, protectAliases }) =>
                ((cleanupPath.kind !== "symlink" || protectAliases) &&
                  (protectedPath.canonicalPath === cleanupPath.canonicalPath ||
                    isPathInside(cleanupPath.canonicalPath, protectedPath.canonicalPath))) ||
                [
                  protectedPath.trashPath,
                  ...(protectAliases ? protectedPath.sourcePaths : []),
                ].some(
                  (protectedSourcePath) =>
                    protectedSourcePath === cleanupPath.trashPath ||
                    isPathInside(cleanupPath.trashPath, protectedSourcePath),
                ),
            );
            const ownedBySurvivor =
              isPathOwnedBySurvivingAgent(
                nextConfig,
                agentId,
                cleanupPath.path,
                refreshedDatabaseFilePaths,
              ) ||
              (cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath) &&
                resolveRegisteredAgentIdForDir(deleteResult.agentDir) !== agentId);
            if (blockingProtection || ownedBySurvivor) {
              const terminal = ownedBySurvivor || blockingProtection?.terminal === true;
              const note = ownedBySurvivor
                ? "replacement owned by a surviving agent"
                : blockingProtection?.note;
              if (terminal) {
                markCleanupPathDone(cleanupPath, note ?? "protected replacement preserved");
              }
              protectedCleanupPaths.push({
                cleanupPath,
                protectAliases: blockingProtection?.protectAliases ?? false,
                terminal,
                note,
              });
              continue;
            }
            const outcome = cleanupPath.preparationError
              ? cleanupFailure(cleanupPath.path, cleanupPath.preparationError)
              : await removeAgentPath(cleanupPath);
            outcomes.push({
              cleanupPath,
              outcome,
            });
            if ("removed" in outcome) {
              markCleanupPathDone(cleanupPath);
            } else if ("skipped" in outcome) {
              markCleanupPathDone(cleanupPath, outcome.skipped.reason);
              protectedCleanupPaths.push({
                cleanupPath,
                protectAliases: true,
                terminal: true,
                note: outcome.skipped.reason,
              });
            } else {
              protectedCleanupPaths.push({
                cleanupPath,
                protectAliases: true,
                terminal: false,
              });
            }
          }
          for (const { outcome } of outcomes) {
            if ("removed" in outcome) {
              removed.push(outcome.removed);
            } else if ("failed" in outcome) {
              failed.push(outcome.failed);
            }
          }
          if (
            workspaceCleanupPaths.length > 0 &&
            workspaceCleanupPaths.every((cleanupPath) => completedCleanupPaths.has(cleanupPath)) &&
            legacyPlan &&
            statePlan
          ) {
            try {
              await removeLegacyWorkspaceStateForReset(legacyPlan);
              deleteWorkspaceState(statePlan);
            } catch {
              // Best-effort cleanup. A later explicit reset can remove stale rows.
            }
          }
          const agentDirCleanupPaths = cleanupPaths.filter((cleanupPath) =>
            cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath),
          );
          if (
            agentDirCleanupPaths.length > 0 &&
            agentDirCleanupPaths.every((cleanupPath) => completedCleanupPaths.has(cleanupPath))
          ) {
            unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath });
          }
        }
        if (failed.length === 0) {
          unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath });
          if (deleteFiles) {
            unregisterAgentDeleteDatabases(agentId, databasePlan?.registrationPaths ?? []);
          }
          deletion.finish();
        }
        return {
          ok: true,
          agentId,
          removedBindings: deleteResult.removedBindings,
          removed,
          failed,
        };
      });
      respond(true, result, undefined);
    } catch (error) {
      if (error instanceof AgentConfigPreconditionError) {
        respondAgentNotFound(respond, agentId);
        return;
      }
      throw error;
    }
  },
  "agents.files.list": async ({ params, respond, context }) => {
    if (!validateAgentsFilesListParams(params)) {
      respondInvalidMethodParams(
        respond,
        "agents.files.list",
        validateAgentsFilesListParams.errors,
      );
      return;
    }
    const cfg = context.getRuntimeConfig();
    const agentId = resolveAgentIdOrError(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 agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir);
    } catch {
      // Fall back to showing BOOTSTRAP if workspace state cannot be read.
    }
    const files = await listAgentFiles(workspaceDir, { hideBootstrap });
    respond(true, { agentId, workspace: workspaceDir, files }, undefined);
  },
  "agents.files.get": async ({ params, respond, context }) => {
    if (!validateAgentsFilesGetParams(params)) {
      respondInvalidMethodParams(respond, "agents.files.get", validateAgentsFilesGetParams.errors);
      return;
    }
    const resolved = resolveAgentWorkspaceFileOrRespondError(
      params,
      respond,
      context.getRuntimeConfig(),
    );
    if (!resolved) {
      return;
    }
    const { agentId, workspaceDir, name } = resolved;
    const filePath = path.join(workspaceDir, name);
    let safeRead: ReadResult;
    try {
      const workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
      safeRead = await workspaceRoot.read(name, {
        hardlinks: "reject",
        nonBlockingRead: true,
      });
    } catch (err) {
      if (err instanceof FsSafeError && err.code === "not-found") {
        respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
        return;
      }
      if (err instanceof FsSafeError) {
        respondWorkspaceFileUnsafe(respond, name);
        return;
      }
      throw err;
    }
    respond(
      true,
      {
        agentId,
        workspace: workspaceDir,
        file: {
          name,
          path: filePath,
          missing: false,
          size: safeRead.stat.size,
          updatedAtMs: Math.floor(safeRead.stat.mtimeMs),
          content: safeRead.buffer.toString("utf-8"),
        },
      },
      undefined,
    );
  },
  "agents.files.set": async ({ params, respond, context }) => {
    if (!validateAgentsFilesSetParams(params)) {
      respondInvalidMethodParams(respond, "agents.files.set", validateAgentsFilesSetParams.errors);
      return;
    }
    const resolved = resolveAgentWorkspaceFileOrRespondError(
      params,
      respond,
      context.getRuntimeConfig(),
    );
    if (!resolved) {
      return;
    }
    const { agentId, workspaceDir, name } = resolved;
    await fs.mkdir(workspaceDir, { recursive: true });
    const filePath = path.join(workspaceDir, name);
    const content = params.content;
    let workspaceRoot: WorkspaceRoot;
    try {
      workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
      await workspaceRoot.write(name, content, { encoding: "utf8" });
    } catch (err) {
      if (!(err instanceof FsSafeError)) {
        throw err;
      }
      respondWorkspaceFileUnsafe(respond, name);
      return;
    }
    const meta = await statWorkspaceFileSafely(workspaceRoot, workspaceDir, name);
    respond(
      true,
      {
        ok: true,
        agentId,
        workspace: workspaceDir,
        file: {
          name,
          path: filePath,
          missing: false,
          size: meta?.size,
          updatedAtMs: meta?.updatedAtMs,
          content,
        },
      },
      undefined,
    );
  },
};
export { testing as __testing };
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
