From fefba2697d18e59615bdcffe33e0050a3fa797ce Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 03:44:51 +0200
Subject: [PATCH 1/9] Files page: shared types and helpers
---
packages/core/src/memory/editor.ts | 2 +-
packages/core/src/memory/workspace.ts | 15 +++-
packages/shared/src/api.ts | 110 ++++++++++++++++++++++++++
packages/shared/src/models.ts | 3 +-
4 files changed, 125 insertions(+), 5 deletions(-)
diff --git a/packages/core/src/memory/editor.ts b/packages/core/src/memory/editor.ts
index 94fb26bd..275ccefa 100644
--- a/packages/core/src/memory/editor.ts
+++ b/packages/core/src/memory/editor.ts
@@ -22,7 +22,7 @@ export function memoryDigestOf(text: string): string {
}
/** Markdown notes under memory/, sorted; hidden entries and symlinks are skipped. */
-function memoryTopics(repoPath: string): string[] {
+export function memoryTopics(repoPath: string): string[] {
const out: string[] = [];
const walk = (rel: string, depth: number) => {
if (depth > 6 || out.length >= MAX_TOPICS) return;
diff --git a/packages/core/src/memory/workspace.ts b/packages/core/src/memory/workspace.ts
index 485727ee..8d98abd5 100644
--- a/packages/core/src/memory/workspace.ts
+++ b/packages/core/src/memory/workspace.ts
@@ -226,6 +226,15 @@ export function namedBy(agent: Owner): (rel: string) => string | null {
return (rel) => pinnedBy(agent, refs, rel) ?? memoryMentions(refs, rel)[0] ?? null;
}
+/** Every place that names `workspace/` or something in it, read once for many entries: what keeps it, then the memory files. */
+export function namesOf(agent: Owner): (rel: string) => string[] {
+ const refs = references(agent);
+ return (rel) => {
+ const pin = pinnedBy(agent, refs, rel);
+ return [...(pin ? [pin] : []), ...memoryMentions(refs, rel)];
+ };
+}
+
/** Paths in tmp/ the agent's notes name (`tmp/...`, existing ones only) → where they are named. */
function namedScratch(root: string, refs: References): Map {
const found = new Map();
@@ -256,14 +265,14 @@ function inProject(root: string, rel: string): boolean {
return false;
}
-interface Note {
+export interface Note {
rel: string;
bytes: number;
modified: number;
}
/** The agent's notes: Markdown files in the workspace, outside tmp/, uploads/, hidden folders, packages and projects. */
-function listNotes(root: string): Note[] {
+export function listNotes(root: string): Note[] {
const notes: Note[] = [];
const walk = (dir: string, depth: number) => {
let names: string[];
@@ -292,7 +301,7 @@ function listNotes(root: string): Note[] {
}
/** The note's first line as a title. */
-function noteTitle(abs: string): string {
+export function noteTitle(abs: string): string {
try {
const head = readFileSync(abs, "utf8").slice(0, 2_000);
const line = head.split("\n").find((l) => l.trim()) ?? "";
diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts
index 4c5dff47..c8ad532d 100644
--- a/packages/shared/src/api.ts
+++ b/packages/shared/src/api.ts
@@ -555,6 +555,116 @@ export interface AgentFileEntry {
modifiedAt: string;
}
+/* ------------------------------------------------------------------ */
+/* Files page: the human tidies an agent's repository */
+/* ------------------------------------------------------------------ */
+
+/** Size of a file or folder: every file below it counted, symlinks never followed. */
+export interface AgentFileSize {
+ path: string;
+ bytes: number;
+ files: number;
+ /** Newest change of a file in it (a folder without files: its own). */
+ modifiedAt: string | null;
+ /** The count stopped early (a very large folder): at least this much. */
+ partial: boolean;
+}
+
+/** GET /api/files/overview — one row per agent, for picking the one to tidy. */
+export interface AgentFilesOverview {
+ agentId: ID;
+ /** workspace/, memory/ and MEMORY.md together. */
+ bytes: number;
+ workspaceBytes: number;
+ /** Of that, what lies in workspace/tmp (Godmode clears it by itself). */
+ scratchBytes: number;
+ /** Markdown notes in memory/ and workspace/ (tmp/ and uploads/ left out). */
+ notes: number;
+ memoryChars: number;
+ /** Entries in the agent's bin (deleted on the Files page, not yet emptied). */
+ deleted: number;
+ partial: boolean;
+}
+
+/** What the agent keeps about a path, before it is moved or deleted. */
+export interface AgentFileInfo extends AgentFileSize {
+ type: "file" | "dir";
+ /** Where it is named ("MEMORY.md", "the automation …"); empty = nowhere. */
+ namedBy: string[];
+ /** Why it can't be moved or deleted here (null = it can). */
+ locked: string | null;
+}
+
+export type FileReviewKind = "build" | "junk" | "empty" | "duplicate" | "large" | "scratch" | "stale" | "loose" | "unlinked-note" | "broken-link";
+
+/** One thing on the Files page's Review list. */
+export interface FileReviewItem {
+ kind: FileReviewKind;
+ path: string;
+ type: "file" | "dir";
+ bytes: number;
+ files: number;
+ modifiedAt: string | null;
+ /** Why it is on the list, in a sentence. */
+ detail: string;
+ /** Where the agent names it (it may still need it); null = nowhere. */
+ namedBy: string | null;
+ /** duplicate: the copy that stays. broken-link: the file that links to the missing path. */
+ related: string | null;
+}
+
+export interface FileReview {
+ agentId: ID;
+ checkedAt: ISODate;
+ items: FileReviewItem[];
+ /** The look stopped early (a very large repository): there may be more. */
+ partial: boolean;
+}
+
+/** A Markdown note of the agent, for the Notes list. */
+export interface AgentNote {
+ path: string;
+ title: string;
+ bytes: number;
+ modifiedAt: string;
+ /** MEMORY.md, or a note MEMORY.md or another memory note links to (unlinked memory notes are rarely read). */
+ linked: boolean;
+ namedBy: string | null;
+}
+
+export interface AgentFileSearchHit {
+ path: string;
+ type: "file" | "dir";
+ /** 1-based line of the first match in the content; null = the name matched. */
+ line: number | null;
+ snippet: string | null;
+}
+
+/** An entry the human deleted on the Files page: kept in the agent's bin until it is put back or emptied. */
+export interface DeletedAgentFile {
+ id: string;
+ path: string;
+ type: "file" | "dir";
+ bytes: number;
+ files: number;
+ deletedAt: ISODate;
+}
+
+export interface AgentFilesDeleteResult {
+ deleted: DeletedAgentFile[];
+ refused: { path: string; reason: string }[];
+}
+
+export interface AgentFileMoveResult {
+ path: string;
+ /** Memory files whose mentions of the old path now point at the new one. */
+ rewritten: string[];
+}
+
+export interface AgentFileRestoreResult {
+ path: string;
+}
+
export interface GitCommit {
oid: string;
message: string;
diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts
index 21fc9575..e30f6d21 100644
--- a/packages/shared/src/models.ts
+++ b/packages/shared/src/models.ts
@@ -1792,7 +1792,8 @@ export type CleanupId =
| "vm-images"
| "agent-scratch"
| "agent-run-logs"
- | "agent-history";
+ | "agent-history"
+ | "deleted-files";
export interface CleanupEntry {
name: string;
From fb1fc89aaaccd7c3cebefb7b93fca4df206a7d0c Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 03:48:00 +0200
Subject: [PATCH 2/9] Files page: core sizes, review, notes, search, moves and
the bin
---
packages/core/src/agents/files.ts | 941 ++++++++++++++++++++++
packages/core/src/cloud/scope.ts | 19 +-
packages/core/src/server/routes/agents.ts | 57 ++
packages/core/src/services/cleanup.ts | 19 +
4 files changed, 1034 insertions(+), 2 deletions(-)
create mode 100644 packages/core/src/agents/files.ts
diff --git a/packages/core/src/agents/files.ts b/packages/core/src/agents/files.ts
new file mode 100644
index 00000000..b47e0b2e
--- /dev/null
+++ b/packages/core/src/agents/files.ts
@@ -0,0 +1,941 @@
+/**
+ * The Files page: the human looks through an agent's repository and tidies it by hand — folder sizes, the agent's
+ * notes, a search, a review of what clutters it (build output, junk, empty entries, copies, large files, stale and
+ * unsorted entries, memory notes nothing links to), new folders, moves and deletes.
+ *
+ * A delete goes into the agent's bin (`/.deleted-files///`), where it waits to be put back until
+ * the automatic cleanup removes what is older than DELETED_DAYS (services/cleanup.ts). A move points the agent's memory
+ * at the new place. Godmode's own files stay where they are (lockReason), nothing moves while the agent dreams (the
+ * dream's rollback and undo would lose track of it), symlinks are never followed, and every change is committed.
+ */
+import { createHash, randomBytes } from "node:crypto";
+import { createReadStream, existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { cp, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import { basename, dirname, extname, join } from "node:path";
+import type {
+ Agent,
+ AgentFileInfo,
+ AgentFileMoveResult,
+ AgentFileRestoreResult,
+ AgentFileSearchHit,
+ AgentFileSize,
+ AgentFilesDeleteResult,
+ AgentFilesOverview,
+ AgentNote,
+ DeletedAgentFile,
+ FileReview,
+ FileReviewItem,
+ FileReviewKind,
+} from "@godmode/shared";
+import { formatBytes } from "@godmode/shared";
+import { config } from "../config";
+import { all } from "../db";
+import { isDreaming } from "../memory/dreaming";
+import { memoryTopics } from "../memory/editor";
+import { MEMORY_DIR, MEMORY_FILE, canRestore, memoryChars, snapshotMemory } from "../memory/files";
+import { listNotes, namesOf, noteTitle, WORKSPACE_DIR } from "../memory/workspace";
+import { HttpError, badRequest, conflict, notFound, now } from "../util";
+import { AGENT_REPO_DIRS } from "./claudeMd";
+import { commitAllInLock, resolveRepoPath, withRepoLock } from "./repo";
+import { withRepo } from "./service";
+
+const BIN = ".deleted-files";
+/** What the human deleted waits this long in the agent's bin, then the automatic cleanup removes it. */
+export const DELETED_DAYS = 30;
+const DAY = 86_400_000;
+/** Entries one size count visits at most (a folder with more counts as "at least"). */
+const COUNT_BUDGET = 400_000;
+const REVIEW_BUDGET = 250_000;
+const SEARCH_BUDGET = 80_000;
+const SEARCH_LIMIT = 100;
+const SEARCH_READ_MAX = 512 * 1024;
+const LARGE = 25 * 1024 * 1024;
+const LARGE_SCRATCH = 50 * 1024 * 1024;
+const STALE_DAYS = 30;
+const RECENT_DAYS = 3;
+const HASH_MIN = 1024;
+const HASH_MAX_FILE = 1024 * 1024 * 1024;
+const HASH_BUDGET = 2 * 1024 * 1024 * 1024;
+const KIND_LIMIT = 200;
+const MAX_PATHS = 500;
+const CACHE_MS = 30_000;
+
+const SCRATCH = `${WORKSPACE_DIR}/tmp`;
+const UPLOADS = `${WORKSPACE_DIR}/uploads`;
+
+/** Installed packages and caches: installing or building again brings them back. */
+const BUILD = new Set([
+ "node_modules",
+ ".venv",
+ "venv",
+ "__pycache__",
+ "Pods",
+ "DerivedData",
+ ".next",
+ ".nuxt",
+ ".turbo",
+ ".gradle",
+ ".pytest_cache",
+ ".parcel-cache",
+ ".svelte-kit",
+ ".dart_tool",
+ ".expo",
+]);
+/** Build output, when it lies next to a project file. */
+const OUTPUT = new Set(["build", "dist", "out", "target", ".build"]);
+const PROJECT_FILES = ["package.json", "build.gradle", "build.gradle.kts", "pubspec.yaml", "Cargo.toml", "pyproject.toml", "setup.py", "Package.swift", "pom.xml", "Podfile"];
+const JUNK = /^(\.DS_Store|Thumbs\.db|desktop\.ini|npm-debug\.log.*|yarn-error\.log|~\$.*|\.~lock\..*#)$|\.(log|tmp|temp|bak|swp|crdownload|part)$/i;
+const TEXT = /\.(md|markdown|txt|json|jsonl|csv|tsv|ya?ml|toml|html?|css|xml|sh|py|m?js|ts|tsx|jsx|sql|env\.example)$/i;
+const NOTE = /\.(md|markdown)$/i;
+const ROOT_KNOWN = new Set([MEMORY_FILE, "CLAUDE.md", ".gitignore", ...AGENT_REPO_DIRS]);
+
+/* ------------------------------------------------------------------ */
+/* What stays put */
+/* ------------------------------------------------------------------ */
+
+const FIXED: Record = {
+ "": "The agent's repository itself.",
+ [MEMORY_FILE]: "The agent's memory: edit it here, but it stays.",
+ "CLAUDE.md": "Written from the agent's settings — change them in its Settings tab.",
+ ".gitignore": "Keeps scratch files and run logs out of the agent's history.",
+ [WORKSPACE_DIR]: "Every agent has this folder.",
+ [MEMORY_DIR]: "Every agent has this folder.",
+ [SCRATCH]: "Godmode's scratch folder: what lies in it goes by itself a week after its last change.",
+ [UPLOADS]: "Where the files you attach in chats land.",
+};
+const GODMODE_DIRS: Record = {
+ conversations: "Chat transcripts are written by Godmode.",
+ runs: "Run logs are written by Godmode — Settings → Cleanup removes the old ones.",
+ state: "A snapshot of the agent's settings, written by Godmode.",
+};
+
+/** Why `rel` can't be moved or deleted on the Files page (null = it can). */
+export function lockReason(rel: string): string | null {
+ if (rel in FIXED) return FIXED[rel]!;
+ const parts = rel.split("/");
+ if (parts.length === 2 && parts[1] === ".gitkeep" && (AGENT_REPO_DIRS as readonly string[]).includes(parts[0]!)) return "Keeps the folder in the agent's history.";
+ return GODMODE_DIRS[parts[0]!] ?? null;
+}
+
+/** Where moved entries and new folders may go: inside workspace/ or memory/. */
+function placeable(rel: string): boolean {
+ return rel.startsWith(`${WORKSPACE_DIR}/`) || rel.startsWith(`${MEMORY_DIR}/`);
+}
+
+/** No symlink on the way from the repository down to `rel` (with `self`, the entry itself neither). */
+function noLink(root: string, rel: string, self = true): boolean {
+ const parts = rel ? rel.split("/") : [];
+ let probe = root;
+ for (let i = 0; i < parts.length; i++) {
+ probe = join(probe, parts[i]!);
+ if (i === parts.length - 1 && !self) break;
+ try {
+ if (lstatSync(probe).isSymbolicLink()) return false;
+ } catch {
+ return true;
+ }
+ }
+ return true;
+}
+
+function entryOf(root: string, rel: string) {
+ try {
+ return lstatSync(join(root, rel));
+ } catch {
+ return null;
+ }
+}
+
+/** A path of the repository as the human sent it, confined to it ("" = the repository itself). */
+function repoPath(agent: Agent, input: string): string {
+ return resolveRepoPath(agent.repoPath, input).rel;
+}
+
+function refuseWhileDreaming(agent: Agent) {
+ if (isDreaming(agent.id)) {
+ throw conflict(`${agent.name} is dreaming (tidying its memory and workspace) right now — try again when the dream has ended.`);
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Sizes */
+/* ------------------------------------------------------------------ */
+
+interface Tally {
+ bytes: number;
+ files: number;
+ modified: number;
+ partial: boolean;
+}
+
+const NONE: Tally = { bytes: 0, files: 0, modified: 0, partial: false };
+
+function add(a: Tally, b: Tally): Tally {
+ return { bytes: a.bytes + b.bytes, files: a.files + b.files, modified: Math.max(a.modified, b.modified), partial: a.partial || b.partial };
+}
+
+function limiter(max: number) {
+ let active = 0;
+ const waiting: (() => void)[] = [];
+ return async (fn: () => Promise): Promise => {
+ if (active >= max) await new Promise((resolve) => waiting.push(resolve));
+ active++;
+ try {
+ return await fn();
+ } finally {
+ active--;
+ waiting.shift()?.();
+ }
+ };
+}
+
+const io = limiter(48);
+
+/** Counts what lies below a path (symlinks counted, never followed) within one budget of entries. */
+class Counter {
+ constructor(private left = COUNT_BUDGET) {}
+
+ async of(abs: string): Promise {
+ if (this.left-- <= 0) return { ...NONE, partial: true };
+ const st = await io(() => lstat(abs)).catch(() => null);
+ if (!st) return NONE;
+ if (!st.isDirectory()) return { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false };
+ const names = await io(() => readdir(abs)).catch(() => [] as string[]);
+ const total = (await Promise.all(names.map((n) => this.of(join(abs, n))))).reduce(add, NONE);
+ return total.files ? total : { ...total, modified: st.mtimeMs };
+ }
+}
+
+const sizeCache = new Map }>();
+
+function cachedTally(abs: string): Promise {
+ const hit = sizeCache.get(abs);
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.tally;
+ const tally = new Counter().of(abs);
+ sizeCache.set(abs, { at: Date.now(), tally });
+ return tally;
+}
+
+const reviewCache = new Map }>();
+let overviewCache: { at: number; rows: Promise } | null = null;
+
+/** Something in the agent's repository changed here: sizes and the review are looked at again. */
+function forget(agent: Agent) {
+ for (const key of sizeCache.keys()) if (key === agent.repoPath || key.startsWith(`${agent.repoPath}/`)) sizeCache.delete(key);
+ reviewCache.delete(agent.id);
+ overviewCache = null;
+}
+
+function sizeOf(path: string, t: Tally): AgentFileSize {
+ return { path, bytes: t.bytes, files: t.files, modifiedAt: t.modified ? new Date(t.modified).toISOString() : null, partial: t.partial };
+}
+
+/** Sizes of what lies directly in a folder of the repository. */
+export async function folderSizes(agentId: string, dir: string): Promise {
+ const agent = await withRepo(agentId);
+ const rel = repoPath(agent, dir);
+ const st = entryOf(agent.repoPath, rel);
+ if (!st || !noLink(agent.repoPath, rel)) throw notFound("Folder");
+ if (!st.isDirectory()) throw badRequest("Path is not a folder");
+ const names = readdirSync(join(agent.repoPath, rel)).filter((n) => n.toLowerCase() !== ".git");
+ return Promise.all(
+ names.map(async (name) => {
+ const child = rel ? `${rel}/${name}` : name;
+ return sizeOf(child, await cachedTally(join(agent.repoPath, child)));
+ }),
+ );
+}
+
+function memoryNoteCount(repoPath: string): number {
+ return memoryTopics(repoPath).filter((p) => NOTE.test(p)).length;
+}
+
+function binDir(agentId: string): string {
+ return join(config().agentsDir, BIN, agentId);
+}
+
+/** One row per agent: what its files take, how many notes it keeps, how long its memory is. */
+export function filesOverview(): Promise {
+ if (overviewCache && Date.now() - overviewCache.at < CACHE_MS * 4) return overviewCache.rows;
+ const rows = Promise.all(
+ all<{ id: string; repo_path: string }>("SELECT id, repo_path FROM agents ORDER BY name").map(async (a) => {
+ const workspace = join(a.repo_path, WORKSPACE_DIR);
+ const [ws, scratch, memory, memoryFile] = await Promise.all([
+ cachedTally(workspace),
+ cachedTally(join(workspace, "tmp")),
+ cachedTally(join(a.repo_path, MEMORY_DIR)),
+ cachedTally(join(a.repo_path, MEMORY_FILE)),
+ ]);
+ let notes = memoryNoteCount(a.repo_path);
+ if (entryOf(a.repo_path, WORKSPACE_DIR)?.isDirectory()) notes += listNotes(workspace).length;
+ return {
+ agentId: a.id,
+ bytes: ws.bytes + memory.bytes + memoryFile.bytes,
+ workspaceBytes: ws.bytes,
+ scratchBytes: scratch.bytes,
+ notes,
+ memoryChars: memoryChars(a.repo_path),
+ deleted: deletedEntries(a.id).length,
+ partial: ws.partial || memory.partial,
+ };
+ }),
+ );
+ overviewCache = { at: Date.now(), rows };
+ rows.catch(() => (overviewCache = null));
+ return rows;
+}
+
+/* ------------------------------------------------------------------ */
+/* Who names what */
+/* ------------------------------------------------------------------ */
+
+const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+/** `rel` as a path of its own in a text (not the start of a longer name). */
+function mention(rel: string, flags = ""): RegExp {
+ return new RegExp(`(? string[] {
+ const files = Object.entries(snapshotMemory(repoPath).files);
+ return (rel) => {
+ const full = mention(rel);
+ const name = basename(rel);
+ const stem = name.replace(/\.(md|markdown|txt)$/i, "");
+ const near = new RegExp(`\\]\\((?:\\./)?${escape(name)}(?:#[^)]*)?\\)`);
+ const wiki = new RegExp(`\\[\\[${escape(stem)}(?:\\|[^\\]]*)?\\]\\]`);
+ return files
+ .filter(([path, text]) => path !== rel && (full.test(text) || wiki.test(text) || (dirname(path) === dirname(rel) && near.test(text))))
+ .map(([path]) => path);
+ };
+}
+
+/** Where the agent names a path of its repository (read once for many paths). */
+function namer(agent: Agent): (rel: string) => string[] {
+ const workspace = namesOf(agent);
+ const memory = memoryLinks(agent.repoPath);
+ return (rel) => {
+ if (rel.startsWith(`${WORKSPACE_DIR}/`)) return workspace(rel.slice(WORKSPACE_DIR.length + 1));
+ if (rel.startsWith(`${MEMORY_DIR}/`)) return memory(rel);
+ return [];
+ };
+}
+
+/** Size, what names them and whether they may go, for the paths the human is about to move or delete. */
+export async function inspectFiles(agentId: string, paths: string[]): Promise {
+ const agent = await withRepo(agentId);
+ const named = namer(agent);
+ const out: AgentFileInfo[] = [];
+ for (const input of paths.slice(0, MAX_PATHS)) {
+ const rel = repoPath(agent, input);
+ const st = entryOf(agent.repoPath, rel);
+ if (!st) continue;
+ const tally = st.isSymbolicLink() ? { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false } : await cachedTally(join(agent.repoPath, rel));
+ out.push({ ...sizeOf(rel, tally), type: st.isDirectory() ? "dir" : "file", namedBy: named(rel), locked: lockReason(rel) });
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Review */
+/* ------------------------------------------------------------------ */
+
+/** Which kind wins when one path fits several. */
+const PRIORITY: FileReviewKind[] = ["build", "junk", "empty", "duplicate", "large", "scratch", "stale", "loose", "unlinked-note", "broken-link"];
+
+interface Candidate {
+ kind: FileReviewKind;
+ rel: string;
+ dir: boolean;
+ tally: Tally;
+ detail: string;
+ related?: string;
+}
+
+function day(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+function isProjectFolder(abs: string): boolean {
+ return PROJECT_FILES.some((f) => existsSync(join(abs, f)));
+}
+
+async function sha(abs: string): Promise {
+ return new Promise((resolve) => {
+ const hash = createHash("sha256");
+ createReadStream(abs)
+ .on("data", (chunk) => hash.update(chunk))
+ .on("error", () => resolve(null))
+ .on("end", () => resolve(hash.digest("hex")));
+ });
+}
+
+class Reviewer {
+ readonly found: Candidate[] = [];
+ readonly copies = new Map();
+ partial = false;
+ private left = REVIEW_BUDGET;
+ private counter = new Counter();
+
+ constructor(
+ private root: string,
+ private at: number,
+ ) {}
+
+ private add(kind: FileReviewKind, rel: string, dir: boolean, tally: Tally, detail: string, related?: string) {
+ this.found.push({ kind, rel, dir, tally, detail, related });
+ }
+
+ /** Walk a folder of the repository: what clutters it, and the files that may be copies of each other. */
+ async walk(rel: string, depth: number): Promise {
+ if (this.left <= 0 || depth > 14) {
+ this.partial = true;
+ return false;
+ }
+ const abs = join(this.root, rel);
+ const entries = await io(() => readdir(abs, { withFileTypes: true })).catch(() => null);
+ if (!entries) return false;
+ this.left -= entries.length;
+ let kept = 0;
+ for (const entry of entries) {
+ const name = entry.name;
+ const child = `${rel}/${name}`;
+ const childAbs = join(abs, name);
+ if (name.toLowerCase() === ".git") {
+ kept++;
+ continue;
+ }
+ if (entry.isSymbolicLink()) {
+ kept++;
+ continue;
+ }
+ if (entry.isDirectory()) {
+ if (child === SCRATCH) {
+ kept++;
+ await this.scratch();
+ continue;
+ }
+ if (BUILD.has(name) || (OUTPUT.has(name) && isProjectFolder(abs))) {
+ const tally = await this.counter.of(childAbs);
+ this.add("build", child, true, tally, BUILD.has(name) ? `Installed packages or a cache (${name}) — installing or building again brings it back.` : `Build output (${name}) — building the project again brings it back.`);
+ kept++;
+ continue;
+ }
+ // A project of its own (a clone): its files are its business, not clutter of the agent.
+ if (existsSync(join(childAbs, ".git"))) {
+ kept++;
+ continue;
+ }
+ if (name.startsWith(".")) {
+ kept++;
+ continue;
+ }
+ const filled = await this.walk(child, depth + 1);
+ if (filled) kept++;
+ else {
+ const st = await lstat(childAbs).catch(() => null);
+ this.add("empty", child, true, { ...NONE, modified: st?.mtimeMs ?? 0 }, "An empty folder.");
+ }
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ const st = await io(() => lstat(childAbs)).catch(() => null);
+ if (!st) continue;
+ const tally: Tally = { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false };
+ if (JUNK.test(name)) {
+ this.add("junk", child, false, tally, name === ".DS_Store" ? "Finder's folder settings — macOS writes them again when needed." : /\.log$/i.test(name) ? "A log file." : "A temporary or backup file.");
+ continue;
+ }
+ if (name === ".gitkeep") {
+ kept++;
+ continue;
+ }
+ kept++;
+ if (st.size === 0) {
+ this.add("empty", child, false, tally, "An empty file.");
+ continue;
+ }
+ if (st.size >= LARGE) this.add("large", child, false, tally, `${formatBytes(st.size)} — one of the largest files here.`);
+ if (st.size >= HASH_MIN && st.size <= HASH_MAX_FILE) {
+ const list = this.copies.get(st.size) ?? [];
+ list.push({ rel: child, modified: st.mtimeMs });
+ this.copies.set(st.size, list);
+ }
+ }
+ return kept > 0;
+ }
+
+ /** tmp/ goes by itself: only what is large is worth a look now. */
+ private async scratch() {
+ const abs = join(this.root, SCRATCH);
+ for (const name of await readdir(abs).catch(() => [] as string[])) {
+ if (name.startsWith(".")) continue;
+ const rel = `${SCRATCH}/${name}`;
+ const st = await lstat(join(abs, name)).catch(() => null);
+ if (!st || st.isSymbolicLink()) continue;
+ const tally = await this.counter.of(join(abs, name));
+ const discarded = /^discarded-(\d{4}-\d{2}-\d{2})/.exec(name);
+ if (discarded) {
+ this.add("scratch", rel, st.isDirectory(), tally, `A dream sorted these out on ${discarded[1]} — the daily cleanup removes them a week after their last change.`);
+ } else if (tally.bytes >= LARGE_SCRATCH) {
+ const left = Math.max(0, Math.ceil((tally.modified + 7 * DAY - this.at) / DAY));
+ this.add("scratch", rel, st.isDirectory(), tally, `Scratch in tmp/ — goes by itself ${left ? `in about ${left} day${left === 1 ? "" : "s"}` : "with the next daily cleanup"}, unless a note names it.`);
+ }
+ }
+ }
+
+ /** Top of the repository and of workspace/: stray files, and entries nobody touched for long. */
+ async tops() {
+ for (const name of await readdir(this.root).catch(() => [] as string[])) {
+ if (name.startsWith(".") || ROOT_KNOWN.has(name)) continue;
+ const st = await lstat(join(this.root, name)).catch(() => null);
+ if (!st || st.isSymbolicLink()) continue;
+ const tally = await this.counter.of(join(this.root, name));
+ if (JUNK.test(name)) this.add("junk", name, st.isDirectory(), tally, /\.log$/i.test(name) ? "A log file at the top of the repository." : "A temporary file at the top of the repository.");
+ else this.add("loose", name, st.isDirectory(), tally, "Lies at the top of the repository, outside workspace/ — move it into workspace/ or delete it.");
+ }
+ const ws = join(this.root, WORKSPACE_DIR);
+ for (const name of await readdir(ws).catch(() => [] as string[])) {
+ const rel = `${WORKSPACE_DIR}/${name}`;
+ if (name.startsWith(".") || rel === SCRATCH || rel === UPLOADS) continue;
+ const st = await lstat(join(ws, name)).catch(() => null);
+ if (!st || st.isSymbolicLink()) continue;
+ const tally = await this.counter.of(join(ws, name));
+ const idle = Math.floor((this.at - tally.modified) / DAY);
+ if (tally.modified && idle >= STALE_DAYS) {
+ this.add("stale", rel, st.isDirectory(), tally, `Nothing in it changed for ${idle} days (last ${day(tally.modified)}).`);
+ } else if (!st.isDirectory() && idle >= RECENT_DAYS) {
+ this.add("loose", rel, false, tally, "Lies loose at the top of workspace/ — sort it into a folder.");
+ }
+ }
+ }
+
+ /** Files with the same content: the copy the agent names (or the oldest) stays. */
+ async duplicates(named: (rel: string) => string[]) {
+ let budget = HASH_BUDGET;
+ for (const [size, list] of this.copies) {
+ if (list.length < 2) continue;
+ const byHash = new Map();
+ for (const file of list) {
+ if (budget < size) {
+ this.partial = true;
+ break;
+ }
+ budget -= size;
+ const hash = await sha(join(this.root, file.rel));
+ if (!hash) continue;
+ byHash.set(hash, [...(byHash.get(hash) ?? []), file]);
+ }
+ for (const group of byHash.values()) {
+ if (group.length < 2) continue;
+ const keep = [...group].sort(
+ (a, b) =>
+ Number(named(b.rel).length > 0) - Number(named(a.rel).length > 0) ||
+ Number(b.rel.startsWith(`${MEMORY_DIR}/`)) - Number(a.rel.startsWith(`${MEMORY_DIR}/`)) ||
+ Number(a.rel.startsWith(`${SCRATCH}/`)) - Number(b.rel.startsWith(`${SCRATCH}/`)) ||
+ a.modified - b.modified ||
+ a.rel.length - b.rel.length,
+ )[0]!;
+ for (const copy of group) {
+ if (copy === keep) continue;
+ this.add("duplicate", copy.rel, false, { bytes: size, files: 1, modified: copy.modified, partial: false }, `Same content as ${keep.rel}.`, keep.rel);
+ }
+ }
+ }
+ }
+
+ /** Memory notes nothing links to, and links to notes that don't exist. */
+ memory(links: (rel: string) => string[]) {
+ for (const rel of memoryTopics(this.root)) {
+ if (!NOTE.test(rel) || links(rel).length) continue;
+ const st = entryOf(this.root, rel);
+ if (!st) continue;
+ this.add("unlinked-note", rel, false, { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false }, "No link from MEMORY.md or another note — the agent may never read it. Link it, merge it, or delete it.");
+ }
+ const pattern = /(?();
+ for (const [file, text] of Object.entries(snapshotMemory(this.root).files)) {
+ for (const m of text.matchAll(pattern)) {
+ const target = m[0];
+ if (seen.has(target) || target.split("/").some((p) => p === ".." || p === "") || entryOf(this.root, target)) continue;
+ seen.add(target);
+ this.add("broken-link", target, false, NONE, `${file} links to it, but it doesn't exist.`, file);
+ }
+ }
+ }
+}
+
+/** What clutters the agent's repository, by kind; what the agent still names is marked, never hidden. */
+export async function reviewFiles(agentId: string, fresh = false): Promise {
+ const agent = await withRepo(agentId);
+ const hit = reviewCache.get(agent.id);
+ if (!fresh && hit && Date.now() - hit.at < CACHE_MS * 2) return hit.review;
+ const review = buildReview(agent);
+ reviewCache.set(agent.id, { at: Date.now(), review });
+ review.catch(() => reviewCache.delete(agent.id));
+ return review;
+}
+
+async function buildReview(agent: Agent): Promise {
+ const at = Date.now();
+ const r = new Reviewer(agent.repoPath, at);
+ const named = namer(agent);
+ if (entryOf(agent.repoPath, WORKSPACE_DIR)?.isDirectory()) await r.walk(WORKSPACE_DIR, 0);
+ if (entryOf(agent.repoPath, MEMORY_DIR)?.isDirectory()) await r.walk(MEMORY_DIR, 0);
+ await r.tops();
+ await r.duplicates(named);
+ r.memory(memoryLinks(agent.repoPath));
+
+ const byPath = new Map();
+ for (const c of r.found) {
+ const prev = byPath.get(c.rel);
+ if (!prev || PRIORITY.indexOf(c.kind) < PRIORITY.indexOf(prev.kind)) byPath.set(c.rel, c);
+ }
+ // A folder already on the list covers what lies in it.
+ const folders = [...byPath.values()].filter((c) => c.dir).map((c) => `${c.rel}/`);
+ const items: FileReviewItem[] = [];
+ const perKind = new Map();
+ const sorted = [...byPath.values()]
+ .filter((c) => !lockReason(c.rel) && !folders.some((f) => c.rel.startsWith(f)))
+ .sort((a, b) => PRIORITY.indexOf(a.kind) - PRIORITY.indexOf(b.kind) || b.tally.bytes - a.tally.bytes || a.rel.localeCompare(b.rel));
+ for (const c of sorted) {
+ const n = perKind.get(c.kind) ?? 0;
+ if (n >= KIND_LIMIT) {
+ r.partial = true;
+ continue;
+ }
+ perKind.set(c.kind, n + 1);
+ const names = c.kind === "broken-link" ? [] : named(c.rel);
+ items.push({
+ kind: c.kind,
+ path: c.rel,
+ type: c.dir ? "dir" : "file",
+ bytes: c.tally.bytes,
+ files: c.tally.files,
+ modifiedAt: c.tally.modified ? new Date(c.tally.modified).toISOString() : null,
+ detail: c.detail,
+ namedBy: names[0] ?? null,
+ related: c.related ?? null,
+ });
+ }
+ return { agentId: agent.id, checkedAt: new Date(at).toISOString(), items, partial: r.partial };
+}
+
+/* ------------------------------------------------------------------ */
+/* Notes and search */
+/* ------------------------------------------------------------------ */
+
+/** The agent's Markdown notes: MEMORY.md, memory/ and the workspace (tmp/, uploads/ and clones left out). */
+export async function listAgentNotes(agentId: string): Promise {
+ const agent = await withRepo(agentId);
+ const links = memoryLinks(agent.repoPath);
+ const named = namer(agent);
+ const notes: AgentNote[] = [];
+ const push = (rel: string, linked: boolean) => {
+ const st = entryOf(agent.repoPath, rel);
+ if (!st?.isFile()) return;
+ const names = named(rel);
+ notes.push({
+ path: rel,
+ title: noteTitle(join(agent.repoPath, rel)) || basename(rel),
+ bytes: st.size,
+ modifiedAt: st.mtime.toISOString(),
+ linked,
+ namedBy: names[0] ?? null,
+ });
+ };
+ push(MEMORY_FILE, true);
+ for (const rel of memoryTopics(agent.repoPath)) if (NOTE.test(rel)) push(rel, links(rel).length > 0);
+ const ws = join(agent.repoPath, WORKSPACE_DIR);
+ if (entryOf(agent.repoPath, WORKSPACE_DIR)?.isDirectory()) {
+ for (const note of listNotes(ws)) {
+ const rel = `${WORKSPACE_DIR}/${note.rel}`;
+ push(rel, named(rel).length > 0);
+ }
+ }
+ return notes;
+}
+
+function snippetOf(line: string, at: number, length: number): string {
+ const start = Math.max(0, at - 60);
+ const end = Math.min(line.length, at + length + 100);
+ return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`;
+}
+
+/** Files and folders whose name holds `query`, and text files that contain it (first match per file). */
+export async function searchAgentFiles(agentId: string, query: string): Promise {
+ const agent = await withRepo(agentId);
+ const needle = query.trim().toLowerCase();
+ if (needle.length < 2) return [];
+ const hits: AgentFileSearchHit[] = [];
+ let left = SEARCH_BUDGET;
+ const visit = async (rel: string, depth: number): Promise => {
+ if (hits.length >= SEARCH_LIMIT || left <= 0 || depth > 12) return;
+ const entries = await readdir(join(agent.repoPath, rel), { withFileTypes: true }).catch(() => []);
+ left -= entries.length;
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
+ if (hits.length >= SEARCH_LIMIT) return;
+ const name = entry.name;
+ if (name.startsWith(".") || BUILD.has(name) || entry.isSymbolicLink()) continue;
+ const child = `${rel}/${name}`;
+ const nameHit = name.toLowerCase().includes(needle);
+ if (entry.isDirectory()) {
+ if (nameHit) hits.push({ path: child, type: "dir", line: null, snippet: null });
+ if (!existsSync(join(agent.repoPath, child, ".git"))) await visit(child, depth + 1);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ if (nameHit) {
+ hits.push({ path: child, type: "file", line: null, snippet: null });
+ continue;
+ }
+ if (!TEXT.test(name)) continue;
+ const st = await lstat(join(agent.repoPath, child)).catch(() => null);
+ if (!st || st.size > SEARCH_READ_MAX) continue;
+ const text = await readFile(join(agent.repoPath, child), "utf8").catch(() => "");
+ const lower = text.toLowerCase();
+ const at = lower.indexOf(needle);
+ if (at < 0) continue;
+ const lineStart = lower.lastIndexOf("\n", at) + 1;
+ const lineEnd = lower.indexOf("\n", at);
+ const line = text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd);
+ hits.push({ path: child, type: "file", line: text.slice(0, at).split("\n").length, snippet: snippetOf(line, at - lineStart, needle.length) });
+ }
+ };
+ if (entryOf(agent.repoPath, MEMORY_FILE)) {
+ const text = readFileSync(join(agent.repoPath, MEMORY_FILE), "utf8");
+ const at = text.toLowerCase().indexOf(needle);
+ if (at >= 0) {
+ const lineStart = text.lastIndexOf("\n", at) + 1;
+ const lineEnd = text.indexOf("\n", at);
+ hits.push({ path: MEMORY_FILE, type: "file", line: text.slice(0, at).split("\n").length, snippet: snippetOf(text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd), at - lineStart, needle.length) });
+ }
+ }
+ await visit(MEMORY_DIR, 1);
+ await visit(WORKSPACE_DIR, 1);
+ return hits.sort((a, b) => Number(a.line !== null) - Number(b.line !== null));
+}
+
+/* ------------------------------------------------------------------ */
+/* Changes */
+/* ------------------------------------------------------------------ */
+
+/** A new, empty folder inside workspace/ or memory/. */
+export async function createFolder(agentId: string, input: string): Promise<{ path: string }> {
+ const agent = await withRepo(agentId);
+ const rel = repoPath(agent, input);
+ if (!placeable(rel)) throw badRequest("New folders go inside workspace/ or memory/.");
+ if (!noLink(agent.repoPath, rel)) throw badRequest("The folder would lie behind a link.");
+ if (entryOf(agent.repoPath, rel)) throw conflict(`${rel} already exists.`);
+ await withRepoLock(agent.repoPath, () => mkdir(join(agent.repoPath, rel), { recursive: true }));
+ forget(agent);
+ return { path: rel };
+}
+
+/** Point the agent's memory at `to` wherever it named `from` (its own absolute paths too, never another agent's). */
+function repointMemory(repoPath: string, from: string, to: string): string[] {
+ const pattern = mention(from, "g");
+ const own = (text: string, at: number) => at === 0 || text[at - 1] !== "/" || text.slice(0, at - 1).endsWith(repoPath) || text.slice(Math.max(0, at - 2), at) === "./";
+ const changed: string[] = [];
+ for (const [path, text] of Object.entries(snapshotMemory(repoPath).files)) {
+ const next = text.replace(pattern, (match: string, at: number) => (own(text, at) ? to : match));
+ if (next === text || !canRestore(repoPath, path) || !noLink(repoPath, path)) continue;
+ writeFileSync(join(repoPath, path), next);
+ changed.push(path);
+ }
+ return changed;
+}
+
+/** Rename or move an entry inside workspace/ or memory/ (never over another one); the memory follows it. */
+export async function moveFile(agentId: string, fromInput: string, toInput: string): Promise {
+ const agent = await withRepo(agentId);
+ const from = repoPath(agent, fromInput);
+ const to = repoPath(agent, toInput);
+ const locked = lockReason(from);
+ if (locked) throw new HttpError(403, locked, "locked");
+ if (!placeable(to) || lockReason(to)) throw badRequest("Entries move inside workspace/ or memory/.");
+ if (to === from) return { path: to, rewritten: [] };
+ if (to.startsWith(`${from}/`)) throw badRequest("A folder can't move into itself.");
+ refuseWhileDreaming(agent);
+ return withRepoLock(agent.repoPath, async () => {
+ const source = entryOf(agent.repoPath, from);
+ if (!source) throw notFound(from);
+ if (!noLink(agent.repoPath, from, false) || !noLink(agent.repoPath, to, false)) throw badRequest("The way there goes through a link.");
+ const target = entryOf(agent.repoPath, to);
+ // Only a new spelling of the same name (on a case-insensitive disk) may find "itself" there.
+ if (target && !(target.ino === source.ino && target.dev === source.dev && from.toLowerCase() === to.toLowerCase())) {
+ throw conflict(`${to} already exists — pick another name.`);
+ }
+ await mkdir(dirname(join(agent.repoPath, to)), { recursive: true });
+ await rename(join(agent.repoPath, from), join(agent.repoPath, to));
+ const rewritten = repointMemory(agent.repoPath, from, to);
+ await commitAllInLock(agent.repoPath, `Move ${from} to ${to}`);
+ forget(agent);
+ return { path: to, rewritten };
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* The bin */
+/* ------------------------------------------------------------------ */
+
+const ENTRY_ID = /^[a-z0-9]{8,40}$/;
+
+interface Stored extends DeletedAgentFile {
+ dir: string;
+}
+
+function readEntry(dir: string, id: string): Stored | null {
+ try {
+ const meta = JSON.parse(readFileSync(join(dir, id, "entry.json"), "utf8")) as DeletedAgentFile;
+ if (typeof meta.path !== "string" || !existsSync(join(dir, id, "item"))) return null;
+ return { ...meta, id, dir: join(dir, id) };
+ } catch {
+ return null;
+ }
+}
+
+function deletedEntries(agentId: string): Stored[] {
+ const dir = binDir(agentId);
+ let ids: string[];
+ try {
+ ids = readdirSync(dir);
+ } catch {
+ return [];
+ }
+ return ids
+ .filter((id) => ENTRY_ID.test(id))
+ .map((id) => readEntry(dir, id))
+ .filter((e): e is Stored => !!e)
+ .sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
+}
+
+const strip = ({ dir: _dir, ...entry }: Stored): DeletedAgentFile => entry;
+
+export async function listDeletedFiles(agentId: string): Promise {
+ const agent = await withRepo(agentId);
+ return deletedEntries(agent.id).map(strip);
+}
+
+/** Entries of every agent's bin deleted more than `days` ago (agents removed since included): for the cleanup. */
+export function expiredDeletedFiles(at = Date.now(), days = DELETED_DAYS): { agentId: string; path: string; dir: string; deletedAt: string }[] {
+ const root = join(config().agentsDir, BIN);
+ let agents: string[];
+ try {
+ agents = readdirSync(root);
+ } catch {
+ return [];
+ }
+ return agents.flatMap((agentId) =>
+ deletedEntries(agentId)
+ .filter((e) => at - Date.parse(e.deletedAt) >= days * DAY)
+ .map((e) => ({ agentId, path: e.path, dir: e.dir, deletedAt: e.deletedAt })),
+ );
+}
+
+async function moveAside(from: string, to: string) {
+ try {
+ await rename(from, to);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
+ await cp(from, to, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
+ await rm(from, { recursive: true, force: true });
+ }
+}
+
+/** Move entries into the agent's bin, in one commit. What must stay is refused, the rest goes. */
+export async function deleteFiles(agentId: string, inputs: string[]): Promise {
+ const agent = await withRepo(agentId);
+ refuseWhileDreaming(agent);
+ const paths = [...new Set(inputs.slice(0, MAX_PATHS).map((p) => repoPath(agent, p)))];
+ // A folder that goes takes what lies in it along.
+ const top = paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(`${q}/`)));
+ const result: AgentFilesDeleteResult = { deleted: [], refused: [] };
+ await withRepoLock(agent.repoPath, async () => {
+ const bin = binDir(agent.id);
+ for (const rel of top) {
+ const locked = lockReason(rel);
+ if (locked) {
+ result.refused.push({ path: rel, reason: locked });
+ continue;
+ }
+ const st = entryOf(agent.repoPath, rel);
+ if (!st) {
+ result.refused.push({ path: rel, reason: "It isn't there anymore." });
+ continue;
+ }
+ if (!noLink(agent.repoPath, rel, false)) {
+ result.refused.push({ path: rel, reason: "It lies behind a link." });
+ continue;
+ }
+ const tally = st.isDirectory() ? await new Counter().of(join(agent.repoPath, rel)) : { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false };
+ const id = `${Date.now().toString(36)}${randomBytes(5).toString("hex")}`;
+ const dir = join(bin, id);
+ const entry: DeletedAgentFile = { id, path: rel, type: st.isDirectory() ? "dir" : "file", bytes: tally.bytes, files: tally.files, deletedAt: now() };
+ try {
+ await mkdir(dir, { recursive: true, mode: 0o700 });
+ await writeFile(join(dir, "entry.json"), JSON.stringify(entry));
+ await moveAside(join(agent.repoPath, rel), join(dir, "item"));
+ result.deleted.push(entry);
+ } catch (err) {
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
+ result.refused.push({ path: rel, reason: `It couldn't be moved to the bin (${err instanceof Error ? err.message : String(err)}).` });
+ }
+ }
+ if (result.deleted.length) {
+ const first = result.deleted[0]!.path;
+ await commitAllInLock(agent.repoPath, result.deleted.length === 1 ? `Delete ${first}` : `Delete ${first} and ${result.deleted.length - 1} more`);
+ }
+ });
+ for (const rel of paths) if (!top.includes(rel)) result.refused.push({ path: rel, reason: "Goes with the folder it lies in." });
+ forget(agent);
+ return result;
+}
+
+/** A free place next to `rel`: "notes (restored).md", "notes (restored 2).md", … */
+function freePlace(root: string, rel: string): string {
+ if (!entryOf(root, rel)) return rel;
+ const ext = extname(rel);
+ const base = ext ? rel.slice(0, -ext.length) : rel;
+ for (let i = 1; ; i++) {
+ const next = `${base} (restored${i > 1 ? ` ${i}` : ""})${ext}`;
+ if (!entryOf(root, next)) return next;
+ }
+}
+
+/** Put a deleted entry back where it was (next to it, when something new took its place). */
+export async function restoreDeletedFile(agentId: string, id: string): Promise {
+ const agent = await withRepo(agentId);
+ if (!ENTRY_ID.test(id)) throw notFound("Deleted entry");
+ refuseWhileDreaming(agent);
+ return withRepoLock(agent.repoPath, async () => {
+ const entry = readEntry(binDir(agent.id), id);
+ if (!entry) throw notFound("Deleted entry");
+ const original = repoPath(agent, entry.path);
+ if (lockReason(original)) throw badRequest("It can't go back there.");
+ if (!noLink(agent.repoPath, original, false)) throw badRequest("The way back goes through a link.");
+ const rel = freePlace(agent.repoPath, original);
+ await mkdir(dirname(join(agent.repoPath, rel)), { recursive: true });
+ await moveAside(join(entry.dir, "item"), join(agent.repoPath, rel));
+ await rm(entry.dir, { recursive: true, force: true });
+ await commitAllInLock(agent.repoPath, `Restore ${rel}`);
+ forget(agent);
+ return { path: rel };
+ });
+}
+
+/** Remove deleted entries for good: one (`id`), or the whole bin. */
+export async function purgeDeletedFiles(agentId: string, id?: string): Promise<{ removed: number; bytes: number }> {
+ const agent = await withRepo(agentId);
+ if (id !== undefined && !ENTRY_ID.test(id)) throw notFound("Deleted entry");
+ const entries = deletedEntries(agent.id).filter((e) => id === undefined || e.id === id);
+ if (id !== undefined && !entries.length) throw notFound("Deleted entry");
+ let bytes = 0;
+ for (const e of entries) {
+ await rm(e.dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
+ bytes += e.bytes;
+ }
+ overviewCache = null;
+ return { removed: entries.length, bytes };
+}
diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts
index ac845183..d7152662 100644
--- a/packages/core/src/cloud/scope.ts
+++ b/packages/core/src/cloud/scope.ts
@@ -142,6 +142,18 @@ const RULES: [methods: string, path: string, rule: Rule][] = [
["GET", "/api/agents/:id/commands", A],
["GET", "/api/agents/:id/files", A],
["GET|PUT", "/api/agents/:id/file", A],
+ ["GET", "/api/files/overview", A],
+ ["GET", "/api/agents/:id/files/sizes", A],
+ ["POST", "/api/agents/:id/files/inspect", A],
+ ["GET", "/api/agents/:id/files/review", A],
+ ["GET", "/api/agents/:id/files/notes", A],
+ ["GET", "/api/agents/:id/files/search", A],
+ ["POST", "/api/agents/:id/files/folder", A],
+ ["POST", "/api/agents/:id/files/move", A],
+ ["POST", "/api/agents/:id/files/delete", A],
+ ["GET|DELETE", "/api/agents/:id/files/deleted", A],
+ ["POST", "/api/agents/:id/files/deleted/:entryId/restore", A],
+ ["DELETE", "/api/agents/:id/files/deleted/:entryId", A],
["GET|PUT", "/api/agents/:id/memory", A],
["GET", "/api/agents/:id/commits", A],
["GET", "/api/agents/:id/heartbeat", A],
@@ -408,8 +420,11 @@ const RULES: [methods: string, path: string, rule: Rule][] = [
["POST", "/api/budgets/release", A],
];
-/** Writes a viewer may make: resolving a chat's files changes nothing. */
-const VIEWER_WRITES: [string, string][] = [["POST", "/api/conversations/:id/files"]];
+/** Writes a viewer may make: resolving a chat's files and looking at an agent's before a delete change nothing. */
+const VIEWER_WRITES: [string, string][] = [
+ ["POST", "/api/conversations/:id/files"],
+ ["POST", "/api/agents/:id/files/inspect"],
+];
/** Reads a viewer may not make: pictures of the screen, codes and keys. */
const VIEWER_HIDDEN_READS = ["/api/computer/thumbnail", "/api/computer/sources", "/api/vms/:id/screenshot", "/api/android/devices/:id/screenshot", "/api/totp/codes", "/api/ssh/local-keys"];
diff --git a/packages/core/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts
index b4d7bfdb..73a9e67d 100644
--- a/packages/core/src/server/routes/agents.ts
+++ b/packages/core/src/server/routes/agents.ts
@@ -39,6 +39,20 @@ import { dreamOverview, getDream, isDreaming, revertDream, startDream } from "..
import { isMemoryPath } from "../../memory/files";
import { MAX_MEMORY_EDIT_BYTES, readAgentMemory, writeAgentMemory } from "../../memory/editor";
import { resolveRepoPath } from "../../agents/repo";
+import {
+ createFolder,
+ deleteFiles,
+ filesOverview,
+ folderSizes,
+ inspectFiles,
+ listAgentNotes,
+ listDeletedFiles,
+ moveFile,
+ purgeDeletedFiles,
+ restoreDeletedFile,
+ reviewFiles,
+ searchAgentFiles,
+} from "../../agents/files";
import { getSettings } from "../../services/settings";
import { conflict } from "../../util";
import { requestDevice } from "../auth";
@@ -293,6 +307,49 @@ export function registerAgentRoutes(app: Hono): void {
return c.json({ ok: true });
});
+ /* Files page: sizes, review, notes, search, moves and the bin -------- */
+
+ const repoPathInput = z.string().trim().max(1024);
+ const pathList = z.array(repoPathInput.min(1)).min(1).max(500);
+
+ app.get("/api/files/overview", async (c) => c.json(await filesOverview()));
+
+ app.get("/api/agents/:id/files/sizes", async (c) => c.json(await folderSizes(c.req.param("id"), c.req.query("path") ?? "")));
+
+ app.post("/api/agents/:id/files/inspect", async (c) => {
+ const { paths } = await body(c, z.object({ paths: pathList }));
+ return c.json(await inspectFiles(c.req.param("id"), paths));
+ });
+
+ app.get("/api/agents/:id/files/review", async (c) => c.json(await reviewFiles(c.req.param("id"), c.req.query("fresh") === "1")));
+
+ app.get("/api/agents/:id/files/notes", async (c) => c.json(await listAgentNotes(c.req.param("id"))));
+
+ app.get("/api/agents/:id/files/search", async (c) => c.json(await searchAgentFiles(c.req.param("id"), (c.req.query("q") ?? "").slice(0, 200))));
+
+ app.post("/api/agents/:id/files/folder", async (c) => {
+ const { path } = await body(c, z.object({ path: repoPathInput.min(1) }));
+ return c.json(await createFolder(c.req.param("id"), path));
+ });
+
+ app.post("/api/agents/:id/files/move", async (c) => {
+ const { from, to } = await body(c, z.object({ from: repoPathInput.min(1), to: repoPathInput.min(1) }));
+ return c.json(await moveFile(c.req.param("id"), from, to));
+ });
+
+ app.post("/api/agents/:id/files/delete", async (c) => {
+ const { paths } = await body(c, z.object({ paths: pathList }));
+ return c.json(await deleteFiles(c.req.param("id"), paths));
+ });
+
+ app.get("/api/agents/:id/files/deleted", async (c) => c.json(await listDeletedFiles(c.req.param("id"))));
+
+ app.delete("/api/agents/:id/files/deleted", async (c) => c.json(await purgeDeletedFiles(c.req.param("id"))));
+
+ app.post("/api/agents/:id/files/deleted/:entryId/restore", async (c) => c.json(await restoreDeletedFile(c.req.param("id"), c.req.param("entryId"))));
+
+ app.delete("/api/agents/:id/files/deleted/:entryId", async (c) => c.json(await purgeDeletedFiles(c.req.param("id"), c.req.param("entryId"))));
+
/* Memory editor (MEMORY.md only; phones reach this, never the other files) */
app.get("/api/agents/:id/memory", async (c) => c.json(await readAgentMemory(c.req.param("id"))));
diff --git a/packages/core/src/services/cleanup.ts b/packages/core/src/services/cleanup.ts
index 306a4eb2..18060684 100644
--- a/packages/core/src/services/cleanup.ts
+++ b/packages/core/src/services/cleanup.ts
@@ -14,6 +14,7 @@ import { tmpdir } from "node:os";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { CleanupEntry, CleanupId, CleanupItem, CleanupReport, CleanupResult, CleanupRun, HealthCheck, StorageArea, StorageUsage } from "@godmode/shared";
import { formatBytes } from "@godmode/shared";
+import { DELETED_DAYS, expiredDeletedFiles } from "../agents/files";
import { withRepoLock } from "../agents/repo";
import { namedBy } from "../memory/workspace";
import { backupInProgress } from "../backup/backup";
@@ -39,6 +40,7 @@ const NAMES = {
"agent-scratch": "Agents' scratch files",
"agent-run-logs": "Old run logs",
"agent-history": "Agent file history",
+ "deleted-files": "Deleted files",
database: "Database",
"old-logs": "Old logs",
"vm-downloads": "Unfinished VM downloads",
@@ -57,6 +59,7 @@ export const RECOMMENDED: CleanupId[] = [
"agent-scratch",
"agent-run-logs",
"agent-history",
+ "deleted-files",
"database",
"old-logs",
];
@@ -758,6 +761,21 @@ async function vmDownloads(scan: Scan): Promise {
};
}
+async function deletedFiles(scan: Scan): Promise {
+ const agents = new Map(all<{ id: string; name: string }>("SELECT id, name FROM agents").map((a) => [a.id, a.name]));
+ const targets: Target[] = [];
+ for (const entry of expiredDeletedFiles(scan.at)) {
+ const t = await target(scan, entry.dir, `${agents.get(entry.agentId) ?? "A removed agent"} · ${entry.path}`);
+ t.entry.modifiedAt = entry.deletedAt;
+ targets.push(t);
+ }
+ return {
+ id: "deleted-files",
+ detail: `What you deleted on the Files page more than ${DELETED_DAYS} days ago. Until then it waits in the agent's bin, to be put back.`,
+ targets,
+ };
+}
+
const TRASH_STAMP = /-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})(?:-(\d{3}))?Z(?:-\d+)?$/;
function trashedAt(name: string): number {
@@ -809,6 +827,7 @@ const SCANNERS: Record Promise | Cat
"agent-scratch": agentScratch,
"agent-run-logs": agentRunLogs,
"agent-history": agentHistory,
+ "deleted-files": deletedFiles,
database,
"old-logs": oldLogs,
"vm-downloads": vmDownloads,
From 47ddbeb2602f2a4815ec9b32da752b16f22537b6 Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 03:49:22 +0200
Subject: [PATCH 3/9] Files page: tests for sizes, bin, moves, review, notes
and search
---
packages/core/test/agent-files.test.ts | 230 +++++++++++++++++++++++++
1 file changed, 230 insertions(+)
create mode 100644 packages/core/test/agent-files.test.ts
diff --git a/packages/core/test/agent-files.test.ts b/packages/core/test/agent-files.test.ts
new file mode 100644
index 00000000..d4ef74ba
--- /dev/null
+++ b/packages/core/test/agent-files.test.ts
@@ -0,0 +1,230 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { Agent, AgentFileInfo, AgentFileMoveResult, AgentFileSearchHit, AgentFileSize, AgentFilesDeleteResult, AgentNote, DeletedAgentFile, FileReview, GitCommit } from "@godmode/shared";
+import { loadConfig } from "../src/config";
+import { closeDb, openDb, run } from "../src/db";
+import { setLogLevel } from "../src/log";
+import { resetSettingsCache } from "../src/services/settings";
+import { createApp } from "../src/server/app";
+import { getAccessToken } from "../src/server/auth";
+import { expiredDeletedFiles } from "../src/agents/files";
+import { runCleanup, scanCleanup } from "../src/services/cleanup";
+
+let dataDir: string;
+let app: ReturnType;
+let token: string;
+let agent: Agent;
+
+async function call(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
+ const headers: Record = { authorization: `Bearer ${token}` };
+ if (body !== undefined) headers["content-type"] = "application/json";
+ const res = await app.request(`http://127.0.0.1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: res.status, data: (await res.json()) as T };
+}
+
+const at = (rel: string) => join(agent.repoPath, rel);
+
+function put(rel: string, content: string | Buffer, daysAgo = 0) {
+ mkdirSync(join(at(rel), ".."), { recursive: true });
+ writeFileSync(at(rel), content);
+ if (daysAgo) {
+ const t = new Date(Date.now() - daysAgo * 86_400_000);
+ utimesSync(at(rel), t, t);
+ }
+}
+
+beforeAll(async () => {
+ setLogLevel("error");
+ dataDir = mkdtempSync(join(tmpdir(), "godmode-agent-files-"));
+ loadConfig({ dataDir });
+ openDb(join(dataDir, "test.db"));
+ resetSettingsCache();
+ app = createApp();
+ token = getAccessToken();
+ agent = (await call("POST", "/api/agents", { name: "Tidy Bot" })).data;
+ await call("GET", `/api/agents/${agent.id}/files?path=`);
+});
+
+afterAll(() => {
+ closeDb();
+ resetSettingsCache();
+ rmSync(dataDir, { recursive: true, force: true });
+});
+
+describe("files page", () => {
+ test("folder sizes count every file below, without following links", async () => {
+ put("workspace/reports/a.md", "x".repeat(1000));
+ put("workspace/reports/deep/b.txt", "y".repeat(500));
+ symlinkSync("/etc", at("workspace/reports/outside"));
+ const sizes = (await call("GET", `/api/agents/${agent.id}/files/sizes?path=workspace`)).data;
+ const reports = sizes.find((s) => s.path === "workspace/reports")!;
+ expect(reports.files).toBe(3);
+ expect(reports.bytes).toBeGreaterThanOrEqual(1500);
+ expect(reports.bytes).toBeLessThan(1600);
+ expect((await call("GET", `/api/agents/${agent.id}/files/sizes?path=..`)).status).toBe(403);
+ rmSync(at("workspace/reports/outside"));
+ });
+
+ test("delete goes to the bin and comes back; Godmode's own files stay", async () => {
+ put("workspace/old/report.md", "# Old report\n");
+ put("memory/old-note.md", "# Old\n");
+ const res = await call("POST", `/api/agents/${agent.id}/files/delete`, {
+ paths: ["workspace/old", "workspace/old/report.md", "memory/old-note.md", "MEMORY.md", "CLAUDE.md", "runs", "workspace/tmp", "workspace/missing.md"],
+ });
+ expect(res.status).toBe(200);
+ expect(res.data.deleted.map((d) => d.path).sort()).toEqual(["memory/old-note.md", "workspace/old"]);
+ const refused = Object.fromEntries(res.data.refused.map((r) => [r.path, r.reason]));
+ expect(Object.keys(refused).sort()).toEqual(["CLAUDE.md", "MEMORY.md", "runs", "workspace/missing.md", "workspace/old/report.md", "workspace/tmp"]);
+ expect(existsSync(at("workspace/old"))).toBe(false);
+ expect(existsSync(at("MEMORY.md"))).toBe(true);
+
+ const commits = (await call("GET", `/api/agents/${agent.id}/commits`)).data;
+ expect(commits[0]!.message).toMatch(/^Delete (memory\/old-note\.md|workspace\/old) and 1 more$/);
+
+ const bin = (await call("GET", `/api/agents/${agent.id}/files/deleted`)).data;
+ expect(bin).toHaveLength(2);
+ const folder = bin.find((b) => b.path === "workspace/old")!;
+ expect(folder.type).toBe("dir");
+ expect(folder.files).toBe(1);
+
+ // Something new took its place meanwhile: it comes back next to it.
+ put("workspace/old", "new file");
+ const restored = await call<{ path: string }>("POST", `/api/agents/${agent.id}/files/deleted/${folder.id}/restore`);
+ expect(restored.data.path).toBe("workspace/old (restored)");
+ expect(readFileSync(at("workspace/old (restored)/report.md"), "utf8")).toBe("# Old report\n");
+
+ const note = bin.find((b) => b.path === "memory/old-note.md")!;
+ expect((await call("DELETE", `/api/agents/${agent.id}/files/deleted/${note.id}`)).status).toBe(200);
+ expect((await call("GET", `/api/agents/${agent.id}/files/deleted`)).data).toHaveLength(0);
+ expect((await call("POST", `/api/agents/${agent.id}/files/deleted/${note.id}/restore`)).status).toBe(404);
+ expect((await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: [".git/config"] })).status).toBe(403);
+ });
+
+ test("a move points the memory at the new place, never over another entry", async () => {
+ put("workspace/drafts/plan.md", "# Plan\n");
+ put("memory/topic.md", "# Topic\nSee workspace/drafts/plan.md.\n");
+ writeFileSync(at("MEMORY.md"), "# Memory\n- [Topic](memory/topic.md)\n- Plan: workspace/drafts/plan.md\n- Other agent: /x/other/workspace/drafts/plan.md\n");
+ const moved = await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/drafts/plan.md", to: "workspace/plans/q4.md" });
+ expect(moved.status).toBe(200);
+ expect(moved.data.rewritten.sort()).toEqual(["MEMORY.md", "memory/topic.md"]);
+ const memory = readFileSync(at("MEMORY.md"), "utf8");
+ expect(memory).toContain("- Plan: workspace/plans/q4.md");
+ expect(memory).toContain("/x/other/workspace/drafts/plan.md");
+
+ const renamed = await call("POST", `/api/agents/${agent.id}/files/move`, { from: "memory/topic.md", to: "memory/topics/main.md" });
+ expect(renamed.data.rewritten).toEqual(["MEMORY.md"]);
+ expect(readFileSync(at("MEMORY.md"), "utf8")).toContain("(memory/topics/main.md)");
+
+ put("workspace/plans/other.md", "x");
+ expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans/other.md", to: "workspace/plans/q4.md" })).status).toBe(409);
+ expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans", to: "workspace/plans/inner" })).status).toBe(400);
+ expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans/other.md", to: "state/other.md" })).status).toBe(400);
+ expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "MEMORY.md", to: "workspace/m.md" })).status).toBe(403);
+ expect((await call("GET", `/api/agents/${agent.id}/commits`)).data[0]!.message).toBe("Move memory/topic.md to memory/topics/main.md");
+ });
+
+ test("inspect says what names a path and whether it may go", async () => {
+ const info = (await call("POST", `/api/agents/${agent.id}/files/inspect`, { paths: ["workspace/plans/q4.md", "memory/topics/main.md", "CLAUDE.md"] })).data;
+ const by = Object.fromEntries(info.map((i) => [i.path, i]));
+ expect(by["workspace/plans/q4.md"]!.namedBy).toContain("MEMORY.md");
+ expect(by["memory/topics/main.md"]!.namedBy).toEqual(["MEMORY.md"]);
+ expect(by["CLAUDE.md"]!.locked).toContain("settings");
+ });
+
+ test("new folders only inside workspace/ or memory/", async () => {
+ expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "workspace/clients/acme" })).status).toBe(200);
+ expect(existsSync(at("workspace/clients/acme"))).toBe(true);
+ expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "workspace/clients/acme" })).status).toBe(409);
+ expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "runs/x" })).status).toBe(400);
+ });
+
+ test("the review finds clutter and marks what the agent still names", async () => {
+ const big = Buffer.alloc(4096, 7);
+ put("workspace/app/package.json", "{}");
+ put("workspace/app/node_modules/left-pad/index.js", "module.exports = 1;");
+ put("workspace/app/dist/bundle.js", "x");
+ put("workspace/shots/one.png", big, 5);
+ put("workspace/shots/copy-of-one.png", big, 1);
+ put("workspace/.DS_Store", "x");
+ put("workspace/empty.txt", "");
+ mkdirSync(at("workspace/nothing-here"), { recursive: true });
+ put("workspace/archive/2026-08/notes.md", "# August\n", 60);
+ put("workspace/loose-report.md", "# Loose\n", 10);
+ put("firebase-debug.log", "debug");
+ put("memory/forgotten.md", "# Nobody links here\n");
+ writeFileSync(at("MEMORY.md"), `${readFileSync(at("MEMORY.md"), "utf8")}- [Gone](memory/gone.md)\n- Shots: workspace/shots/one.png\n`);
+
+ const review = (await call("GET", `/api/agents/${agent.id}/files/review?fresh=1`)).data;
+ const kind = (path: string) => review.items.find((i) => i.path === path)?.kind;
+ expect(kind("workspace/app/node_modules")).toBe("build");
+ expect(kind("workspace/app/dist")).toBe("build");
+ expect(kind("workspace/app/node_modules/left-pad/index.js")).toBeUndefined();
+ expect(kind("workspace/.DS_Store")).toBe("junk");
+ expect(kind("firebase-debug.log")).toBe("junk");
+ expect(kind("workspace/empty.txt")).toBe("empty");
+ expect(kind("workspace/nothing-here")).toBe("empty");
+ const copy = review.items.find((i) => i.path === "workspace/shots/copy-of-one.png")!;
+ expect(copy.kind).toBe("duplicate");
+ expect(copy.related).toBe("workspace/shots/one.png");
+ expect(kind("workspace/archive")).toBe("stale");
+ expect(kind("workspace/loose-report.md")).toBe("loose");
+ expect(kind("memory/forgotten.md")).toBe("unlinked-note");
+ expect(kind("memory/topics/main.md")).toBeUndefined();
+ const broken = review.items.find((i) => i.kind === "broken-link")!;
+ expect(broken.path).toBe("memory/gone.md");
+ expect(broken.related).toBe("MEMORY.md");
+ expect(review.items.some((i) => i.path === "workspace/uploads" || i.path === "workspace/tmp")).toBe(false);
+ });
+
+ test("notes list and search", async () => {
+ const notes = (await call("GET", `/api/agents/${agent.id}/files/notes`)).data;
+ const by = Object.fromEntries(notes.map((n) => [n.path, n]));
+ expect(by["MEMORY.md"]!.linked).toBe(true);
+ expect(by["memory/topics/main.md"]!.linked).toBe(true);
+ expect(by["memory/forgotten.md"]!.linked).toBe(false);
+ expect(by["workspace/plans/q4.md"]!.title).toBe("Plan");
+ expect(by["workspace/plans/q4.md"]!.namedBy).toBe("MEMORY.md");
+
+ const hits = (await call("GET", `/api/agents/${agent.id}/files/search?q=nobody%20links`)).data;
+ expect(hits).toEqual([{ path: "memory/forgotten.md", type: "file", line: 1, snippet: "# Nobody links here" }]);
+ const byName = (await call("GET", `/api/agents/${agent.id}/files/search?q=q4`)).data;
+ expect(byName.map((h) => h.path)).toContain("workspace/plans/q4.md");
+ expect((await call("GET", `/api/agents/${agent.id}/files/search?q=left-pad`)).data).toEqual([]);
+ });
+
+ test("nothing moves while the agent dreams", async () => {
+ run("INSERT INTO runs (id, agent_id, conversation_id, trigger, status, prompt, created_at) VALUES ('run_dream', ?, 'cnv_dream', 'dream', 'running', '', datetime('now'))", agent.id);
+ run("INSERT INTO dreams (id, agent_id, run_id, status, reason, created_at) VALUES ('drm_x', ?, 'run_dream', 'running', 'manual', datetime('now'))", agent.id);
+ try {
+ expect((await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: ["workspace/loose-report.md"] })).status).toBe(409);
+ expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/loose-report.md", to: "workspace/r.md" })).status).toBe(409);
+ } finally {
+ run("DELETE FROM dreams WHERE id = 'drm_x'");
+ run("DELETE FROM runs WHERE id = 'run_dream'");
+ }
+ });
+
+ test("the cleanup empties what waited in the bin for 30 days", async () => {
+ put("workspace/expired.md", "bye");
+ const del = (await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: ["workspace/expired.md"] })).data;
+ const entry = del.deleted[0]!;
+ const binEntry = join(dataDir, "agents", ".deleted-files", agent.id, entry.id);
+ writeFileSync(join(binEntry, "entry.json"), JSON.stringify({ ...entry, deletedAt: new Date(Date.now() - 31 * 86_400_000).toISOString() }));
+ expect(expiredDeletedFiles().map((e) => e.path)).toEqual(["workspace/expired.md"]);
+ const item = (await scanCleanup()).items.find((i) => i.id === "deleted-files")!;
+ expect(item.count).toBe(1);
+ expect(item.recommended).toBe(true);
+ await runCleanup(["deleted-files"]);
+ expect(existsSync(binEntry)).toBe(false);
+ expect(readdirSync(join(dataDir, "agents", ".deleted-files", agent.id))).toEqual([]);
+ });
+
+ test("overview row per agent", async () => {
+ const rows = (await call<{ agentId: string; notes: number; bytes: number }[]>("GET", "/api/files/overview")).data;
+ const row = rows.find((r) => r.agentId === agent.id)!;
+ expect(row.notes).toBeGreaterThanOrEqual(4);
+ expect(row.bytes).toBeGreaterThan(0);
+ });
+});
From a4d659895bc4f15b81a858a16c8fcc7bc879b9b0 Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 03:52:39 +0200
Subject: [PATCH 4/9] Files page: shared file editor with split view, API
client
---
.../components/agents/detail/memory-tab.tsx | 316 +---------------
.../src/components/files/file-editor.tsx | 358 ++++++++++++++++++
.../src/components/settings/cleanup-items.tsx | 2 +
apps/desktop/src/lib/api.ts | 28 ++
apps/desktop/src/lib/queryKeys.ts | 7 +
5 files changed, 406 insertions(+), 305 deletions(-)
create mode 100644 apps/desktop/src/components/files/file-editor.tsx
diff --git a/apps/desktop/src/components/agents/detail/memory-tab.tsx b/apps/desktop/src/components/agents/detail/memory-tab.tsx
index 256e136a..8f3f52ad 100644
--- a/apps/desktop/src/components/agents/detail/memory-tab.tsx
+++ b/apps/desktop/src/components/agents/detail/memory-tab.tsx
@@ -1,34 +1,13 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useMemo, useState } from "react";
import { Link } from "react-router";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { formatDistanceToNowStrict } from "date-fns";
-import {
- Brain,
- ChevronRight,
- Eye,
- FileCode2,
- FileJson,
- FileText,
- Folder,
- FolderOpen,
- Lock,
- Moon,
- PencilLine,
- RotateCcw,
- Save,
-} from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { ChevronRight, Folder, FolderOpen, FolderSearch } from "lucide-react";
import type { Agent, AgentFileEntry } from "@godmode/shared";
-import { api, ApiRequestError, errorMessage } from "@/lib/api";
+import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
-import { isMac, modKey } from "@/lib/desktop";
import { cn } from "@/lib/utils";
-import { Kbd } from "@/components/common";
-import { Markdown } from "@/components/chat/markdown";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
-import { Spinner } from "@/components/ui/spinner";
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
AlertDialog,
AlertDialogAction,
@@ -39,39 +18,11 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { FileEditor, fileIcon, nameOf, parentOf } from "@/components/files/file-editor";
import { DreamsPanel, useDreamingNow } from "./dreams-panel";
-import { BrokenLinks, brokenMemoryLinks, MemoryBudget, memoryLinkTarget, memorySections, SectionMenu, type MemorySection } from "./memory-editor-parts";
const PINNED = ["MEMORY.md", "CLAUDE.md"];
const HIDDEN = new Set([".git"]);
-const MAX_EDITABLE = 512 * 1024;
-
-/** Why a file can't be edited here (null = editable). */
-function readOnlyReason(path: string, size: number): string | null {
- if (path === "CLAUDE.md") return "Generated from the agent's settings — edit the instructions in Settings instead.";
- if (path.startsWith("state/")) return "Snapshot written by Godmode — changes would be overwritten.";
- if (path.startsWith("runs/")) return "Raw run log — read-only.";
- if (path.startsWith("conversations/")) return "Conversation transcript — read-only.";
- if (size > MAX_EDITABLE) return "This file is too large to edit here.";
- return null;
-}
-
-/** MEMORY.md and memory/** — a running dream owns these (the core refuses edits meanwhile). */
-function isMemoryPath(path: string) {
- return path === "MEMORY.md" || path.startsWith("memory/");
-}
-
-/** The file changed on the server since the draft started — saving would overwrite someone else's edit. */
-class StaleDraftError extends Error {}
-
-function parentOf(path: string) {
- const i = path.lastIndexOf("/");
- return i === -1 ? "" : path.slice(0, i);
-}
-
-function nameOf(path: string) {
- return path.slice(path.lastIndexOf("/") + 1);
-}
/** Normalise listing entries to repo-relative paths directly inside `dir`. */
function childrenOf(entries: AgentFileEntry[], dir: string): AgentFileEntry[] {
@@ -96,20 +47,6 @@ function childrenOf(entries: AgentFileEntry[], dir: string): AgentFileEntry[] {
});
}
-function fileIcon(path: string) {
- if (path === "MEMORY.md") return ;
- if (path === "CLAUDE.md") return ;
- if (/\.(json|jsonl)$/.test(path)) return ;
- if (/\.(ts|tsx|js|py|sh|yaml|yml|toml)$/.test(path)) return ;
- return ;
-}
-
-function formatSize(n: number) {
- if (n < 1024) return `${n} B`;
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
-}
-
export function MemoryTab({ agent }: { agent: Agent }) {
const [selected, setSelected] = useState<{ path: string; size: number } | null>({ path: "MEMORY.md", size: 0 });
const [expanded, setExpanded] = useState>(() => new Set(["memory"]));
@@ -140,11 +77,16 @@ export function MemoryTab({ agent }: { agent: Agent }) {
-
+
@@ -278,239 +220,3 @@ function Tree({
);
}
-
-function FileEditor({
- agentId,
- agentName,
- path,
- size,
- dreaming,
- onDirtyChange,
- onOpenFile,
-}: {
- agentId: string;
- agentName: string;
- path: string;
- size: number;
- /** A dream of the agent is running: memory files are locked until it ends. */
- dreaming: boolean;
- onDirtyChange: (dirty: boolean) => void;
- /** A link in the preview points at another note in the repository. */
- onOpenFile: (path: string) => void;
-}) {
- const qc = useQueryClient();
- const file = useQuery({ queryKey: qk.agentFile(agentId, path), queryFn: () => api.agents.readFile(agentId, path) });
- const original = file.data?.content ?? "";
- /** The edit and the file content it started from, so a rewrite underneath (e.g. by a dream) isn't silently overwritten. */
- const [draft, setDraft] = useState<{ text: string; base: string } | null>(null);
- const value = draft?.text ?? original;
- const dirty = draft !== null && draft.text !== original;
- const conflict = dirty && draft.base !== original;
- const isMarkdown = /\.(md|markdown)$/i.test(path);
- const fixedReason = readOnlyReason(path, Math.max(size, original.length));
- const dreamLock = dreaming && isMemoryPath(path);
- const readOnly = fixedReason ?? (dreamLock ? `${agentName} is dreaming — editing is paused until the dream ends.` : null);
- const [view, setView] = useState<"edit" | "preview">(isMarkdown && fixedReason ? "preview" : "edit");
- const edit = (text: string) => setDraft((d) => ({ text, base: d && d.text !== original ? d.base : original }));
- const textareaRef = useRef(null);
- const previewRef = useRef(null);
- const isMemory = path === "MEMORY.md";
- const memory = useQuery({ queryKey: qk.agentMemory(agentId), queryFn: () => api.agents.memory(agentId), enabled: isMemory });
- const sections = useMemo(() => (isMarkdown ? memorySections(value) : []), [isMarkdown, value]);
- const broken = useMemo(() => (isMemory && memory.data ? brokenMemoryLinks(value, memory.data.topics) : []), [isMemory, memory.data, value]);
-
- const jumpTo = (section: MemorySection) => {
- if (view === "preview") {
- const headings = previewRef.current?.querySelectorAll("h1, h2, h3");
- headings?.[sections.indexOf(section)]?.scrollIntoView({ block: "start", behavior: "smooth" });
- return;
- }
- const el = textareaRef.current;
- if (!el) return;
- const offset = value.split("\n").slice(0, section.line).reduce((n, l) => n + l.length + 1, 0);
- el.focus();
- el.setSelectionRange(offset, offset);
- const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 21;
- el.scrollTop = Math.max(0, section.line * lineHeight - 12);
- };
-
- useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
- useEffect(() => () => onDirtyChange(false), [onDirtyChange]);
-
- const save = useMutation({
- mutationFn: async ({ content, base }: { content: string; base: string | null }) => {
- // Not every writer announces itself (a chat run may rewrite MEMORY.md): check the file is still what the draft started from.
- if (base !== null) {
- const fresh = await api.agents.readFile(agentId, path).catch((err) => {
- if (err instanceof ApiRequestError && err.status === 404) return { path, content: "" };
- throw err;
- });
- if (fresh.content !== base) {
- qc.setQueryData(qk.agentFile(agentId, path), fresh);
- throw new StaleDraftError();
- }
- }
- await api.agents.writeFile(agentId, path, content);
- return content;
- },
- onSuccess: (content) => {
- qc.setQueryData(qk.agentFile(agentId, path), { path, content });
- qc.invalidateQueries({ queryKey: qk.agentCommits(agentId) });
- qc.invalidateQueries({ queryKey: qk.agentFiles(agentId, parentOf(path)) });
- if (isMemory) {
- qc.invalidateQueries({ queryKey: qk.agentDreams(agentId) });
- qc.invalidateQueries({ queryKey: qk.agentMemory(agentId) });
- }
- setDraft(null);
- toast.success(`Saved ${nameOf(path)}`);
- },
- onError: (err) => {
- if (err instanceof StaleDraftError) warnConflict();
- else toast.error(`Couldn't save ${nameOf(path)}`, { description: errorMessage(err) });
- },
- });
- const warnConflict = () =>
- toast.warning(`${nameOf(path)} changed while you were editing`, { description: "Reload it, or overwrite it with your version." });
-
- const saveRef = useRef<() => void>(() => {});
- saveRef.current = () => {
- if (!dirty || readOnly || save.isPending || !draft) return;
- if (conflict) warnConflict();
- else save.mutate({ content: value, base: draft.base });
- };
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- const mod = isMac ? e.metaKey : e.ctrlKey;
- if (mod && e.key.toLowerCase() === "s") {
- e.preventDefault();
- saveRef.current();
- }
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, []);
-
- const lines = value ? value.split("\n").length : 0;
- const missing = file.isError && (file.error as { status?: number }).status === 404;
-
- return (
- <>
-
-
{fileIcon(path)}
-
{path}
- {dirty &&
}
-
-
- {isMarkdown && (
-
v && setView(v as "edit" | "preview")} aria-label="View mode">
-
- {readOnly ? "Source" : "Edit"}
-
-
- Preview
-
-
- )}
- {!fixedReason && (
- <>
-
setDraft(null)}>
- Revert
-
-
saveRef.current()}>
- {save.isPending ? : } Save
- {modKey}S
-
- >
- )}
-
-
-
- {readOnly && (
-
- {fixedReason ? : }
- {readOnly}
- {path === "CLAUDE.md" && (
-
- Open settings
-
- )}
-
- )}
-
- {conflict && (
-
-
- {nameOf(path)} changed while you were editing — {agentName} may have rewritten it. Reload to
- see the new version (your edits are discarded), or overwrite it with yours.
-
-
- setDraft(null)}>
- Reload
-
- save.mutate({ content: value, base: null })}>
- {save.isPending ? : } Overwrite
-
-
-
- )}
-
-
- {file.isLoading ? (
-
- {Array.from({ length: 8 }, (_, i) => (
-
- ))}
-
- ) : file.isError && !missing ? (
-
Couldn't open this file: {errorMessage(file.error)}
- ) : view === "preview" && isMarkdown ? (
-
{
- const target = memoryLinkTarget((e.target as HTMLElement).closest("a")?.getAttribute("href") ?? null);
- if (!target) return;
- e.preventDefault();
- e.stopPropagation();
- onOpenFile(target);
- }}
- >
- {value.trim() ?
{value} :
Empty file.
}
-
- ) : (
-
-
-
- {lines.toLocaleString()} lines
- {formatSize(new Blob([value]).size)}
- {isMemory && memory.data && }
- {isMemory && }
- {file.dataUpdatedAt > 0 && !dirty && (
- loaded {formatDistanceToNowStrict(file.dataUpdatedAt, { addSuffix: true })}
- )}
- {dirty && Unsaved changes }
-
- >
- );
-}
diff --git a/apps/desktop/src/components/files/file-editor.tsx b/apps/desktop/src/components/files/file-editor.tsx
new file mode 100644
index 00000000..e8ef705e
--- /dev/null
+++ b/apps/desktop/src/components/files/file-editor.tsx
@@ -0,0 +1,358 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { formatDistanceToNowStrict } from "date-fns";
+import { Brain, Columns2, Eye, FileArchive, FileAudio, FileCode2, FileImage, FileJson, FileSpreadsheet, FileText, FileVideo, Lock, Moon, PencilLine, RotateCcw, Save } from "lucide-react";
+import { formatBytes } from "@godmode/shared";
+import { api, ApiRequestError, errorMessage } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { isMac, modKey } from "@/lib/desktop";
+import { cn } from "@/lib/utils";
+import { Kbd } from "@/components/common";
+import { Markdown } from "@/components/chat/markdown";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { BrokenLinks, brokenMemoryLinks, MemoryBudget, memorySections, SectionMenu, type MemorySection } from "@/components/agents/detail/memory-editor-parts";
+
+const MAX_EDITABLE = 512 * 1024;
+
+export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp)$/i;
+export const NOTE_FILE = /\.(md|markdown)$/i;
+
+export function parentOf(path: string) {
+ const i = path.lastIndexOf("/");
+ return i === -1 ? "" : path.slice(0, i);
+}
+
+export function nameOf(path: string) {
+ return path.slice(path.lastIndexOf("/") + 1);
+}
+
+/** Why a file can't be edited here (null = editable). */
+export function readOnlyReason(path: string, size: number): string | null {
+ if (path === "CLAUDE.md") return "Generated from the agent's settings — edit the instructions in Settings instead.";
+ if (path.startsWith("state/")) return "Snapshot written by Godmode — changes would be overwritten.";
+ if (path.startsWith("runs/")) return "Raw run log — read-only.";
+ if (path.startsWith("conversations/")) return "Conversation transcript — read-only.";
+ if (size > MAX_EDITABLE) return "This file is too large to edit here.";
+ return null;
+}
+
+/** MEMORY.md and memory/** — a running dream owns these (the core refuses edits meanwhile). */
+export function isMemoryPath(path: string) {
+ return path === "MEMORY.md" || path.startsWith("memory/");
+}
+
+export function fileIcon(path: string, className = "size-4 text-muted-foreground") {
+ if (path === "MEMORY.md") return ;
+ if (path === "CLAUDE.md") return ;
+ if (IMAGE_FILE.test(path)) return ;
+ if (/\.(json|jsonl)$/i.test(path)) return ;
+ if (/\.(csv|tsv|xlsx?|numbers)$/i.test(path)) return ;
+ if (/\.(zip|tar|gz|tgz|7z|rar|dmg|apk|aab|ipa)$/i.test(path)) return ;
+ if (/\.(mp4|mov|webm|mkv)$/i.test(path)) return ;
+ if (/\.(mp3|wav|m4a|ogg|aac)$/i.test(path)) return ;
+ if (/\.(ts|tsx|js|mjs|cjs|py|sh|yaml|yml|toml|html|css|swift|kt|go|rs)$/i.test(path)) return ;
+ return ;
+}
+
+function decodePath(path: string): string {
+ try {
+ return decodeURI(path);
+ } catch {
+ return path;
+ }
+}
+
+/** A file of the repository a clicked link points at (MEMORY.md, memory/…, workspace/…), or null for any other link. */
+export function repoLinkTarget(href: string | null): string | null {
+ if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("/")) return null;
+ const path = decodePath(href.replace(/^\.\//, "").split(/[#?]/)[0]!);
+ if (path.includes("..")) return null;
+ return path === "MEMORY.md" || path.startsWith("memory/") || path.startsWith("workspace/") ? path : null;
+}
+
+/** The file changed on the server since the draft started — saving would overwrite someone else's edit. */
+class StaleDraftError extends Error {}
+
+type View = "edit" | "split" | "preview";
+
+export function FileEditor({
+ agentId,
+ agentName,
+ path,
+ size,
+ dreaming,
+ onDirtyChange,
+ onOpenFile,
+ onSaved,
+ fill = false,
+ defaultView,
+}: {
+ agentId: string;
+ agentName: string;
+ path: string;
+ size: number;
+ /** A dream of the agent is running: memory files are locked until it ends. */
+ dreaming: boolean;
+ onDirtyChange: (dirty: boolean) => void;
+ /** A link in the preview points at another file in the repository. */
+ onOpenFile: (path: string) => void;
+ onSaved?: (path: string) => void;
+ /** Grow with the pane instead of a fixed height. */
+ fill?: boolean;
+ defaultView?: View;
+}) {
+ const qc = useQueryClient();
+ const file = useQuery({ queryKey: qk.agentFile(agentId, path), queryFn: () => api.agents.readFile(agentId, path), retry: false });
+ const original = file.data?.content ?? "";
+ /** The edit and the file content it started from, so a rewrite underneath (e.g. by a dream) isn't silently overwritten. */
+ const [draft, setDraft] = useState<{ text: string; base: string } | null>(null);
+ const value = draft?.text ?? original;
+ const dirty = draft !== null && draft.text !== original;
+ const conflict = dirty && draft.base !== original;
+ const isMarkdown = NOTE_FILE.test(path);
+ const fixedReason = readOnlyReason(path, Math.max(size, original.length));
+ const dreamLock = dreaming && isMemoryPath(path);
+ const readOnly = fixedReason ?? (dreamLock ? `${agentName} is dreaming — editing is paused until the dream ends.` : null);
+ const [view, setView] = useState(isMarkdown ? (fixedReason ? "preview" : (defaultView ?? "edit")) : "edit");
+ const edit = (text: string) => setDraft((d) => ({ text, base: d && d.text !== original ? d.base : original }));
+ const textareaRef = useRef(null);
+ const previewRef = useRef(null);
+ const isMemory = path === "MEMORY.md";
+ const memory = useQuery({ queryKey: qk.agentMemory(agentId), queryFn: () => api.agents.memory(agentId), enabled: isMemory });
+ const sections = useMemo(() => (isMarkdown ? memorySections(value) : []), [isMarkdown, value]);
+ const broken = useMemo(() => (isMemory && memory.data ? brokenMemoryLinks(value, memory.data.topics) : []), [isMemory, memory.data, value]);
+ const showSource = view !== "preview" || !isMarkdown;
+ const showPreview = isMarkdown && view !== "edit";
+
+ const jumpTo = (section: MemorySection) => {
+ if (view === "preview") {
+ const headings = previewRef.current?.querySelectorAll("h1, h2, h3");
+ headings?.[sections.indexOf(section)]?.scrollIntoView({ block: "start", behavior: "smooth" });
+ return;
+ }
+ const el = textareaRef.current;
+ if (!el) return;
+ const offset = value.split("\n").slice(0, section.line).reduce((n, l) => n + l.length + 1, 0);
+ el.focus();
+ el.setSelectionRange(offset, offset);
+ const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 21;
+ el.scrollTop = Math.max(0, section.line * lineHeight - 12);
+ };
+
+ useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
+ useEffect(() => () => onDirtyChange(false), [onDirtyChange]);
+
+ const save = useMutation({
+ mutationFn: async ({ content, base }: { content: string; base: string | null }) => {
+ // Not every writer announces itself (a chat run may rewrite MEMORY.md): check the file is still what the draft started from.
+ if (base !== null) {
+ const fresh = await api.agents.readFile(agentId, path).catch((err) => {
+ if (err instanceof ApiRequestError && err.status === 404) return { path, content: "" };
+ throw err;
+ });
+ if (fresh.content !== base) {
+ qc.setQueryData(qk.agentFile(agentId, path), fresh);
+ throw new StaleDraftError();
+ }
+ }
+ await api.agents.writeFile(agentId, path, content);
+ return content;
+ },
+ onSuccess: (content) => {
+ qc.setQueryData(qk.agentFile(agentId, path), { path, content });
+ qc.invalidateQueries({ queryKey: qk.agentCommits(agentId) });
+ qc.invalidateQueries({ queryKey: qk.agentFiles(agentId, parentOf(path)) });
+ if (isMemory) {
+ qc.invalidateQueries({ queryKey: qk.agentDreams(agentId) });
+ qc.invalidateQueries({ queryKey: qk.agentMemory(agentId) });
+ }
+ setDraft(null);
+ onSaved?.(path);
+ toast.success(`Saved ${nameOf(path)}`);
+ },
+ onError: (err) => {
+ if (err instanceof StaleDraftError) warnConflict();
+ else toast.error(`Couldn't save ${nameOf(path)}`, { description: errorMessage(err) });
+ },
+ });
+ const warnConflict = () =>
+ toast.warning(`${nameOf(path)} changed while you were editing`, { description: "Reload it, or overwrite it with your version." });
+
+ const saveRef = useRef<() => void>(() => {});
+ saveRef.current = () => {
+ if (!dirty || readOnly || save.isPending || !draft) return;
+ if (conflict) warnConflict();
+ else save.mutate({ content: value, base: draft.base });
+ };
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const mod = isMac ? e.metaKey : e.ctrlKey;
+ if (mod && e.key.toLowerCase() === "s") {
+ e.preventDefault();
+ saveRef.current();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ const lines = value ? value.split("\n").length : 0;
+ const words = useMemo(() => (isMarkdown ? (value.match(/\S+/g)?.length ?? 0) : 0), [isMarkdown, value]);
+ const status = file.isError ? (file.error as { status?: number }).status : undefined;
+ const missing = status === 404;
+ const unreadable = status === 415 || status === 413;
+ const height = fill ? "h-full min-h-[18rem]" : "h-[60vh]";
+
+ const preview = (
+ {
+ const target = repoLinkTarget((e.target as HTMLElement).closest("a")?.getAttribute("href") ?? null);
+ if (!target) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenFile(target);
+ }}
+ >
+ {value.trim() ?
{value} :
Empty file.
}
+
+ );
+
+ const source = (
+
diff --git a/apps/desktop/src/components/files/notes-pane.tsx b/apps/desktop/src/components/files/notes-pane.tsx
new file mode 100644
index 00000000..f4a37732
--- /dev/null
+++ b/apps/desktop/src/components/files/notes-pane.tsx
@@ -0,0 +1,131 @@
+import { useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { formatDistanceToNowStrict } from "date-fns";
+import { ArrowDownWideNarrow, Brain, Search, Unplug, X } from "lucide-react";
+import type { Agent, AgentNote } from "@godmode/shared";
+import { formatBytes } from "@godmode/shared";
+import { api, errorMessage } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { parentOf } from "./file-editor";
+import { plural } from "./use-files";
+
+type Sort = "folder" | "modified" | "size";
+
+export function NotesPane({ agent, selected, onSelect }: { agent: Agent; selected: string | null; onSelect: (note: AgentNote) => void }) {
+ const notes = useQuery({ queryKey: qk.agentNotes(agent.id), queryFn: () => api.agents.notes(agent.id), staleTime: 30_000 });
+ const [filter, setFilter] = useState("");
+ const [sort, setSort] = useState("folder");
+ const [onlyUnlinked, setOnlyUnlinked] = useState(false);
+ const needle = filter.trim().toLowerCase();
+ const all = notes.data ?? [];
+ const unlinked = all.filter((n) => n.path.startsWith("memory/") && !n.linked).length;
+
+ const groups = useMemo(() => {
+ let list = all.filter((n) => !needle || n.title.toLowerCase().includes(needle) || n.path.toLowerCase().includes(needle));
+ if (onlyUnlinked) list = list.filter((n) => n.path.startsWith("memory/") && !n.linked);
+ if (sort === "modified") list = [...list].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
+ if (sort === "size") list = [...list].sort((a, b) => b.bytes - a.bytes);
+ if (sort !== "folder") return [{ title: null as string | null, notes: list }];
+ const by = new Map();
+ for (const n of list) {
+ const key = n.path === "MEMORY.md" ? "" : parentOf(n.path);
+ by.set(key, [...(by.get(key) ?? []), n]);
+ }
+ return [...by.entries()].map(([title, notes]) => ({ title: title || "Memory", notes }));
+ }, [all, needle, sort, onlyUnlinked]);
+ const shown = groups.reduce((n, g) => n + g.notes.length, 0);
+
+ return (
+
+
+
+
+ setFilter(e.target.value)} placeholder="Filter notes" className="h-8 pl-8 text-[13px]" aria-label="Filter notes" />
+ {filter && (
+ setFilter("")} className="absolute top-1/2 right-2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-accent">
+
+
+ )}
+
+
+
+
+
+
+
+
+ Sort by
+ setSort(v as Sort)}>
+ Folder
+ Last changed
+ Size
+
+
+
+
+ {unlinked > 0 && (
+
setOnlyUnlinked((v) => !v)}
+ aria-pressed={onlyUnlinked}
+ className={cn("flex items-center gap-2 border-b px-4 py-2 text-left text-xs transition hover:bg-accent/40", onlyUnlinked ? "bg-warning/[0.08]" : "bg-warning/[0.04]")}
+ >
+
+
+ {plural(unlinked, "memory note")} without a link from MEMORY.md
+
+ {onlyUnlinked ? "Show all" : "Show only these"}
+
+ )}
+
+
+ {notes.isLoading ? (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ ))}
+
+ ) : notes.isError ? (
+
{errorMessage(notes.error)}
+ ) : !shown ? (
+
{needle ? "No note matches." : "No notes yet."}
+ ) : (
+ groups.map((g) => (
+
+ {g.title && {g.title === "Memory" ? "Memory" : `${g.title}/`}
}
+
+ {g.notes.map((n) => (
+
+ onSelect(n)}
+ className={cn("flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-left transition hover:bg-accent/50", selected === n.path && "bg-secondary")}
+ >
+ {n.path === "MEMORY.md" ? : }
+
+ {n.title}
+ {sort === "folder" ? n.path.slice(n.path.lastIndexOf("/") + 1) : n.path}
+
+
+ {formatBytes(n.bytes)}
+ {formatDistanceToNowStrict(new Date(n.modifiedAt))}
+
+
+
+ ))}
+
+
+ ))
+ )}
+
+
+ {plural(all.length, "note")} · {formatBytes(all.reduce((n, x) => n + x.bytes, 0))}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/files/overview.tsx b/apps/desktop/src/components/files/overview.tsx
new file mode 100644
index 00000000..629dc213
--- /dev/null
+++ b/apps/desktop/src/components/files/overview.tsx
@@ -0,0 +1,136 @@
+import { useMemo, useState } from "react";
+import { Link } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { motion } from "motion/react";
+import { ArrowUpRight, Brain, Clock3, NotebookText, Trash2 } from "lucide-react";
+import type { Agent, AgentFilesOverview } from "@godmode/shared";
+import { formatBytes, memoryBudget } from "@godmode/shared";
+import { api, errorMessage } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { useBootstrap } from "@/lib/hooks";
+import { cn } from "@/lib/utils";
+import { AgentAvatar } from "@/components/common";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { plural } from "./use-files";
+
+export function useFilesOverview() {
+ return useQuery({ queryKey: qk.filesOverview, queryFn: () => api.files.overview(), staleTime: 60_000 });
+}
+
+function compactChars(n: number) {
+ return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
+}
+
+/** Every agent's files at a glance, the largest first: where tidying pays off. */
+export function FilesOverview({ agents }: { agents: Agent[] }) {
+ const overview = useFilesOverview();
+ const boot = useBootstrap();
+ const budget = memoryBudget(boot.data?.settings.memory.dreaming.maxMemoryChars);
+ const [sort, setSort] = useState<"size" | "name">("size");
+ const rows = useMemo(() => {
+ const by = new Map((overview.data ?? []).map((r) => [r.agentId, r]));
+ const list = agents.map((a) => ({ agent: a, row: by.get(a.id) ?? null }));
+ return sort === "size" ? list.sort((a, b) => (b.row?.bytes ?? 0) - (a.row?.bytes ?? 0)) : list.sort((a, b) => a.agent.name.localeCompare(b.agent.name));
+ }, [agents, overview.data, sort]);
+ const largest = Math.max(1, ...rows.map((r) => r.row?.bytes ?? 0));
+ const total = rows.reduce((n, r) => n + (r.row?.bytes ?? 0), 0);
+ const scratch = rows.reduce((n, r) => n + (r.row?.scratchBytes ?? 0), 0);
+
+ return (
+
+
+
+
+ {overview.data ? (
+ <>
+ {formatBytes(total)} across {plural(rows.length, "agent")}
+ {scratch > 0 && <> · {formatBytes(scratch)} of it scratch in tmp/, which goes by itself>}
+ >
+ ) : (
+ "Counting what every agent keeps…"
+ )}
+
+
+
v && setSort(v as "size" | "name")} aria-label="Sort agents">
+
+ Largest first
+
+
+ A–Z
+
+
+
+ {overview.isError &&
{errorMessage(overview.error)}
}
+
+ {rows.map(({ agent, row }, i) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function AgentCard({ agent, row, largest, memoryLimit }: { agent: Agent; row: AgentFilesOverview | null; largest: number; memoryLimit: number }) {
+ const over = row ? row.memoryChars > memoryLimit : false;
+ return (
+
+
+
+
+
{agent.name}
+
{agent.role || "Agent"}
+
+
+
+
+ {row ? (
+ <>
+
+ {row.partial && ≥ }
+ {formatBytes(row.bytes)}
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {row && (
+ <>
+
+ {plural(row.notes, "note")}
+
+
+ {compactChars(row.memoryChars)} / {compactChars(memoryLimit)}
+
+ {row.scratchBytes > 0 && (
+
+ tmp {formatBytes(row.scratchBytes)}
+
+ )}
+ {row.deleted > 0 && (
+
+ {row.deleted} in bin
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/files/review-pane.tsx b/apps/desktop/src/components/files/review-pane.tsx
new file mode 100644
index 00000000..fa05cc75
--- /dev/null
+++ b/apps/desktop/src/components/files/review-pane.tsx
@@ -0,0 +1,236 @@
+import { useEffect, useMemo, useState, type ComponentType } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { formatDistanceToNowStrict } from "date-fns";
+import { Boxes, CircleCheck, Clock3, Copy, FileWarning, Folder, FolderInput, FolderX, HardDrive, RefreshCw, Sparkles, Trash2, Unlink, Unplug, Wind } from "lucide-react";
+import type { Agent, FileReviewItem, FileReviewKind } from "@godmode/shared";
+import { formatBytes } from "@godmode/shared";
+import { api, errorMessage } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { fileIcon, nameOf } from "./file-editor";
+import { plural } from "./use-files";
+
+const KINDS: Record; safe: boolean }> = {
+ build: { title: "Installed packages and build output", hint: "Installing or building again brings them back.", icon: Boxes, safe: true },
+ junk: { title: "Junk files", hint: "Logs, temporary files and Finder leftovers.", icon: Wind, safe: true },
+ empty: { title: "Empty files and folders", hint: "Nothing in them.", icon: FolderX, safe: true },
+ duplicate: { title: "Copies", hint: "The same content lies somewhere else too; the copy that stays is named.", icon: Copy, safe: true },
+ large: { title: "Large files", hint: "Check whether they are still needed.", icon: HardDrive, safe: false },
+ scratch: { title: "Large scratch in tmp/", hint: "Goes by itself a week after its last change — delete now to free the space today.", icon: Clock3, safe: false },
+ stale: { title: "Untouched for 30 days", hint: "Old reports, exports and experiments: keep what still matters.", icon: Clock3, safe: false },
+ loose: { title: "Unsorted", hint: "Loose files at the top: sort them into a folder.", icon: FolderInput, safe: false },
+ "unlinked-note": { title: "Notes nothing links to", hint: "Memory notes without a link from MEMORY.md are rarely read: link, merge or delete them.", icon: Unplug, safe: false },
+ "broken-link": { title: "Broken links in memory", hint: "The memory points at notes that don't exist: fix the link in the note.", icon: Unlink, safe: false },
+};
+const ORDER = Object.keys(KINDS) as FileReviewKind[];
+
+/** What a review item does when clicked: open it, the note that links (broken link), or the folder. */
+export interface ReviewActions {
+ onOpen: (item: FileReviewItem) => void;
+ onDelete: (paths: string[]) => void;
+ onMove: (paths: string[]) => void;
+}
+
+export function useFileReview(agentId: string) {
+ return useQuery({ queryKey: qk.agentFileReview(agentId), queryFn: () => api.agents.fileReview(agentId), staleTime: 60_000 });
+}
+
+export function ReviewPane({ agent, selected, actions, frozen }: { agent: Agent; selected: string | null; actions: ReviewActions; frozen: boolean }) {
+ const qc = useQueryClient();
+ const review = useFileReview(agent.id);
+ const recheck = useMutation({
+ mutationFn: () => api.agents.fileReview(agent.id, true),
+ onSuccess: (data) => qc.setQueryData(qk.agentFileReview(agent.id), data),
+ });
+ const [picked, setPicked] = useState | null>(null);
+ const items = review.data?.items ?? [];
+ const deletable = (i: FileReviewItem) => i.kind !== "broken-link";
+ // Safe kinds start ticked, unless the agent still names the item.
+ const defaults = useMemo(() => new Set(items.filter((i) => KINDS[i.kind].safe && !i.namedBy).map((i) => i.path)), [items]);
+ const chosen = picked ?? defaults;
+ const groups = useMemo(() => ORDER.map((kind) => ({ kind, items: items.filter((i) => i.kind === kind) })).filter((g) => g.items.length), [items]);
+ const chosenItems = items.filter((i) => chosen.has(i.path) && deletable(i));
+ const chosenBytes = chosenItems.reduce((n, i) => n + i.bytes, 0);
+ const totalBytes = items.filter(deletable).reduce((n, i) => n + i.bytes, 0);
+
+ useEffect(() => setPicked(null), [review.data]);
+
+ const toggle = (paths: string[], on: boolean) =>
+ setPicked(() => {
+ const next = new Set(chosen);
+ for (const p of paths) {
+ if (on) next.add(p);
+ else next.delete(p);
+ }
+ return next;
+ });
+
+ return (
+
+
+
+
{review.data ? (items.length ? `${plural(items.length, "thing")} to look at` : "Nothing to tidy") : "Looking through the files…"}
+
+ {review.data
+ ? `${totalBytes ? `${formatBytes(totalBytes)} could go · ` : ""}checked ${formatDistanceToNowStrict(new Date(review.data.checkedAt), { addSuffix: true })}${review.data.partial ? " · very large, not everything was looked at" : ""}`
+ : "Sizes, copies, junk and notes nothing links to."}
+
+
+
+
+ recheck.mutate()}>
+ {review.isFetching || recheck.isPending ? : }
+
+
+ Check again
+
+
+
+
+ {review.isLoading ? (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+
+
+
+
+ ))}
+
+ ) : review.isError ? (
+
{errorMessage(review.error)}
+ ) : !groups.length ? (
+
+
+
+
+
{agent.name}'s files look tidy
+
No build output, junk, copies or forgotten notes. The nightly dream keeps sorting the workspace too.
+
+ ) : (
+
+ {groups.map((g) => {
+ const meta = KINDS[g.kind];
+ const Icon = meta.icon;
+ const paths = g.items.filter(deletable).map((i) => i.path);
+ const on = paths.filter((p) => chosen.has(p)).length;
+ const bytes = g.items.reduce((n, i) => n + i.bytes, 0);
+ return (
+
+
+ {paths.length > 0 ? (
+
toggle(paths, v === true)}
+ />
+ ) : (
+
+ )}
+
+
+
+ {meta.title} · {g.items.length}{bytes ? ` · ${formatBytes(bytes)}` : ""}
+
+
{meta.hint}
+
+
+
+ {g.items.map((item) => (
+ toggle([item.path], v) : undefined}
+ actions={actions}
+ frozen={frozen}
+ />
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+
+ {groups.length > 0 && (
+
+
+
+ {chosenItems.length ? (
+ <>
+ {plural(chosenItems.length, "item")} selected · {formatBytes(chosenBytes)} · everything goes to the bin first
+ >
+ ) : (
+ "Tick what can go — it waits in the bin for 30 days."
+ )}
+
+
actions.onDelete(chosenItems.map((i) => i.path))}>
+ Delete {chosenItems.length || ""}
+
+
+ )}
+
+ );
+}
+
+function ReviewRow({
+ item,
+ active,
+ checked,
+ onCheck,
+ actions,
+ frozen,
+}: {
+ item: FileReviewItem;
+ active: boolean;
+ checked: boolean;
+ onCheck?: (on: boolean) => void;
+ actions: ReviewActions;
+ frozen: boolean;
+}) {
+ const isDir = item.type === "dir";
+ return (
+
+ {onCheck ? onCheck(v === true)} /> : }
+ actions.onOpen(item)} className="min-w-0 flex-1 text-left">
+
+ {isDir ? : fileIcon(item.path)}
+
+ {item.path}
+
+
+
+ {item.detail}
+ {item.namedBy && named in {item.namedBy} }
+
+
+
+ {item.bytes > 0 && (
+
+ {formatBytes(item.bytes)}
+ {isDir && item.files > 1 && · {item.files.toLocaleString()} }
+
+ )}
+ {item.kind === "loose" && (
+ actions.onMove([item.path])}>
+ Move
+
+ )}
+ {item.kind === "broken-link" && item.related && (
+ actions.onOpen(item)}>
+ Open {nameOf(item.related)}
+
+ )}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/files/use-files.ts b/apps/desktop/src/components/files/use-files.ts
new file mode 100644
index 00000000..9a75d023
--- /dev/null
+++ b/apps/desktop/src/components/files/use-files.ts
@@ -0,0 +1,126 @@
+import { useCallback } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import type { AgentFilesDeleteResult } from "@godmode/shared";
+import { formatBytes } from "@godmode/shared";
+import { api, errorMessage } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { cloudContext, isTauri } from "@/lib/core";
+import { isMac } from "@/lib/desktop";
+import { copyText } from "@/components/chat/copy-button";
+import { nameOf } from "./file-editor";
+
+/** The core runs on this machine: its file manager can show a path. */
+export const canReveal = isTauri && !cloudContext;
+export const REVEAL_LABEL = canReveal ? (isMac ? "Show in Finder" : "Show in folder") : "Copy path";
+
+export function joinPath(dir: string, name: string) {
+ return dir ? `${dir}/${name}` : name;
+}
+
+/** Where new entries may go and entries may be moved: inside workspace/ or memory/. */
+export function placeable(path: string) {
+ return path.startsWith("workspace/") || path.startsWith("memory/") || path === "workspace" || path === "memory";
+}
+
+export function plural(n: number, one: string, many = `${one}s`) {
+ return `${n.toLocaleString()} ${n === 1 ? one : many}`;
+}
+
+/** Everything the Files page shows about an agent goes stale after a change. */
+export function useRefreshFiles(agentId: string) {
+ const qc = useQueryClient();
+ return useCallback(() => {
+ qc.invalidateQueries({ queryKey: qk.agentFilesPage(agentId) });
+ qc.invalidateQueries({ queryKey: ["agents", "files", agentId] });
+ qc.invalidateQueries({ queryKey: ["agents", "file", agentId] });
+ qc.invalidateQueries({ queryKey: qk.agentCommits(agentId) });
+ qc.invalidateQueries({ queryKey: qk.agentMemory(agentId) });
+ qc.invalidateQueries({ queryKey: qk.filesOverview });
+ }, [qc, agentId]);
+}
+
+export function useReveal(repoPath: string) {
+ return useCallback(
+ (rel: string) => {
+ const abs = rel ? `${repoPath}/${rel}` : repoPath;
+ if (!canReveal) {
+ void copyText(abs).then((ok) => ok && toast.success("Path copied"));
+ return;
+ }
+ api.files.reveal(abs).catch((err) => toast.error(`Couldn't open ${isMac ? "Finder" : "the file manager"}`, { description: errorMessage(err) }));
+ },
+ [repoPath],
+ );
+}
+
+/** Delete into the bin, with an Undo on the toast that puts everything back. */
+export function useDeleteFiles(agentId: string, onDone?: (result: AgentFilesDeleteResult) => void) {
+ const refresh = useRefreshFiles(agentId);
+ const restore = useMutation({
+ mutationFn: async (ids: string[]) => {
+ for (const id of ids) await api.agents.restoreDeleted(agentId, id);
+ },
+ onSuccess: (_, ids) => {
+ refresh();
+ toast.success(ids.length === 1 ? "Put back" : `Put ${ids.length} back`);
+ },
+ onError: (err) => {
+ refresh();
+ toast.error("Couldn't put everything back", { description: `${errorMessage(err)} — the rest is in the bin.` });
+ },
+ });
+ return useMutation({
+ mutationFn: (paths: string[]) => api.agents.deleteFiles(agentId, paths),
+ onSuccess: (result) => {
+ refresh();
+ onDone?.(result);
+ const { deleted, refused } = result;
+ if (deleted.length) {
+ const bytes = deleted.reduce((n, d) => n + d.bytes, 0);
+ toast.success(deleted.length === 1 ? `Deleted ${nameOf(deleted[0]!.path)}` : `Deleted ${deleted.length} items`, {
+ description: `${formatBytes(bytes)} moved to the bin.${refused.length ? ` ${plural(refused.length, "item")} stayed.` : ""}`,
+ action: { label: "Undo", onClick: () => restore.mutate(deleted.map((d) => d.id)) },
+ });
+ } else if (refused.length) {
+ toast.error(refused.length === 1 ? `${nameOf(refused[0]!.path)} stays` : "Nothing was deleted", { description: refused[0]!.reason });
+ }
+ },
+ onError: (err) => toast.error("Couldn't delete", { description: errorMessage(err) }),
+ });
+}
+
+/** Move or rename one entry or several, one after the other. */
+export function useMoveFiles(agentId: string, onMoved?: (moves: { from: string; to: string }[]) => void) {
+ const refresh = useRefreshFiles(agentId);
+ return useMutation({
+ mutationFn: async (moves: { from: string; to: string }[]) => {
+ const done: { from: string; to: string }[] = [];
+ const rewritten = new Set();
+ try {
+ for (const m of moves) {
+ const r = await api.agents.moveFile(agentId, m.from, m.to);
+ done.push({ from: m.from, to: r.path });
+ r.rewritten.forEach((p) => rewritten.add(p));
+ }
+ } catch (err) {
+ if (done.length) onMoved?.(done);
+ throw err;
+ }
+ return { done, rewritten: [...rewritten] };
+ },
+ onSuccess: ({ done, rewritten }) => {
+ refresh();
+ onMoved?.(done);
+ const one = done.length === 1 ? done[0]! : null;
+ const renamed = one && one.from.slice(0, one.from.lastIndexOf("/")) === one.to.slice(0, one.to.lastIndexOf("/"));
+ toast.success(one ? (renamed ? `Renamed to ${nameOf(one.to)}` : `Moved ${nameOf(one.to)}`) : `Moved ${done.length} items`, {
+ description: rewritten.length ? `Updated the path in ${rewritten.join(", ")}.` : undefined,
+ });
+ },
+ onError: (err) => {
+ refresh();
+ toast.error("Couldn't move", { description: errorMessage(err) });
+ },
+ });
+}
diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx
index 820eaead..012a5e91 100644
--- a/apps/desktop/src/components/layout/app-shell.tsx
+++ b/apps/desktop/src/components/layout/app-shell.tsx
@@ -10,6 +10,7 @@ import {
ChevronRight,
CreditCard,
Fingerprint,
+ FolderOpen,
Globe,
MonitorUp,
Inbox,
@@ -191,6 +192,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{ to: "/my-tasks", label: "My tasks", icon: , badge: myTasks || undefined },
{ to: "/tasks", label: "Tasks", icon: , badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined },
{ to: "/agents", label: "Agents", icon: , badge: runningCount || undefined },
+ { to: "/files", label: "Files", icon: },
{ to: "/automations", label: "Automations", icon: , badge: attention?.automation || undefined },
{ to: "/activity", label: "Activity", icon: },
{ to: "/calls", label: "Calls", icon: },
@@ -225,6 +227,7 @@ export function AppShell({ children }: { children: ReactNode }) {
easyItem("/integrations", "Connected apps"),
];
const easyMore: NavItem[] = [
+ easyItem("/files", "Files"),
easyItem("/activity", "History"),
easyItem("/calls", "Phone calls"),
easyItem("/messaging", "Chat apps"),
diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx
index 38f987a5..32f1763b 100644
--- a/apps/desktop/src/components/layout/command-palette.tsx
+++ b/apps/desktop/src/components/layout/command-palette.tsx
@@ -12,6 +12,7 @@ import {
BrainCircuit,
Cloud,
CreditCard,
+ FolderOpen,
DatabaseBackup,
Gauge,
Globe,
@@ -350,6 +351,9 @@ export function CommandPalette() {
go("/plugins")}>
{named("/plugins", "Plugins")}
+ go("/files")}>
+ {named("/files", "Files")}
+
go("/activity")}>
{named("/activity", "Activity")}
diff --git a/apps/desktop/src/lib/mode.ts b/apps/desktop/src/lib/mode.ts
index 5a497bcc..ffd8c798 100644
--- a/apps/desktop/src/lib/mode.ts
+++ b/apps/desktop/src/lib/mode.ts
@@ -23,6 +23,7 @@ export const EASY_PAGES: Record a.id === agentId) ?? scoped.data?.find((a) => a.id === agentId);
+
+ if (!agentId) {
+ return (
+ <>
+ } actions={ } />
+ {scoped.isLoading ? : }
+ >
+ );
+ }
+ if (everyone.isLoading) {
+ return (
+ <>
+ } />
+
+
+
+ >
+ );
+ }
+ if (!agent) {
+ return (
+ <>
+ } />
+
+ } title="This agent doesn't exist anymore" action={ All agents } />
+
+ >
+ );
+ }
+ return ;
+}
+
+function CleanupLink() {
+ return (
+
+
+ Cleanup settings
+
+
+ );
+}
+
+function OverviewSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function AgentPicker({ agent, agents }: { agent: Agent; agents: Agent[] }) {
+ const navigate = useNavigate();
+ const sorted = useMemo(() => [...agents].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)), [agents]);
+ return (
+ navigate(`/files/${id}`)}>
+
+
+
+
+ {sorted.map((a) => (
+
+
+
+ {a.name}
+
+
+ ))}
+
+
+ );
+}
+
+function AgentFiles({ agent, agents }: { agent: Agent; agents: Agent[] }) {
+ const [params, setParams] = useSearchParams();
+ const tab = (TABS.find((t) => t === params.get("tab")) ?? "browse") as Tab;
+ const dir = params.get("dir") ?? "workspace";
+ const filePath = params.get("file");
+ const [fileSize, setFileSize] = useState(0);
+ const file = filePath ? { path: filePath, size: fileSize } : null;
+ const [dirty, setDirty] = useState(false);
+ const [pending, setPending] = useState<(() => void) | null>(null);
+ const [deleting, setDeleting] = useState(null);
+ const [moving, setMoving] = useState(null);
+ const [naming, setNaming] = useState(null);
+ const dreaming = useDreamingNow(agent.id);
+ const working = useAgentRunning(agent.id);
+ const refresh = useRefreshFiles(agent.id);
+ const reveal = useReveal(agent.repoPath);
+ const review = useFileReview(agent.id);
+ const bin = useBin(agent.id);
+ const namingDir = naming ? (naming.kind === "rename" ? parentOf(naming.path) : naming.dir) : dir;
+ const listing = useQuery({ queryKey: qk.agentFiles(agent.id, namingDir), queryFn: () => api.agents.files(agent.id, namingDir), enabled: !!naming });
+ const siblings = (listing.data ?? []).map((e) => e.path.slice(e.path.lastIndexOf("/") + 1));
+ const reviewCount = review.data?.items.length ?? 0;
+ const binCount = bin.data?.length ?? 0;
+
+ const update = useCallback(
+ (next: Partial>, replace = true) =>
+ setParams(
+ (p) => {
+ const q = new URLSearchParams(p);
+ for (const [k, v] of Object.entries(next)) {
+ if (v === null || v === undefined || (k === "tab" && v === "browse") || (k === "dir" && v === "workspace")) q.delete(k);
+ else q.set(k, v);
+ }
+ return q;
+ },
+ { replace },
+ ),
+ [setParams],
+ );
+
+ /** Leave unsaved edits only after asking. */
+ const guard = (go: () => void) => (dirty ? setPending(() => go) : go());
+ const openFile = (path: string, size = 0) =>
+ guard(() => {
+ setFileSize(size);
+ update({ file: path });
+ });
+ const setTab = (t: Tab) => update({ tab: t }, false);
+ const setDir = (d: string) => update({ dir: d }, false);
+
+ const afterDelete = (paths: string[]) => {
+ if (filePath && paths.some((p) => filePath === p || filePath.startsWith(`${p}/`))) {
+ setDirty(false);
+ update({ file: null });
+ }
+ if (paths.some((p) => dir === p || dir.startsWith(`${p}/`))) update({ dir: parentOf(paths.find((p) => dir === p || dir.startsWith(`${p}/`))!) || "workspace" });
+ };
+ const afterMove = (moves: { from: string; to: string }[]) => {
+ for (const m of moves) {
+ if (filePath === m.from) update({ file: m.to });
+ else if (filePath?.startsWith(`${m.from}/`)) update({ file: `${m.to}${filePath.slice(m.from.length)}` });
+ if (dir === m.from || dir.startsWith(`${m.from}/`)) update({ dir: `${m.to}${dir.slice(m.from.length)}` });
+ }
+ };
+
+ const fileActions = {
+ onRename: (path: string) => setNaming({ kind: "rename", path }),
+ onMove: (paths: string[]) => setMoving(paths),
+ onDelete: (paths: string[]) => setDeleting(paths),
+ onReveal: reveal,
+ onOpenFile: (path: string) => openFile(path),
+ onSaved: () => refresh(),
+ };
+ const openReviewItem = (item: FileReviewItem) => {
+ if (item.kind === "broken-link" && item.related) return openFile(item.related);
+ if (item.type === "dir") {
+ guard(() => update({ tab: null, dir: item.path, file: null }));
+ return;
+ }
+ openFile(item.path, item.bytes);
+ };
+
+ return (
+ <>
+ }
+ actions={
+ <>
+
+
+ All agents
+
+
+
+ >
+ }
+ />
+
+
+
guard(() => setTab(v as Tab))}>
+
+
+ Browse
+
+
+ Notes
+
+
+ Review
+ {reviewCount > 0 && {reviewCount} }
+
+
+ Bin
+ {binCount > 0 && {binCount} }
+
+
+
+ {dreaming ? (
+
+ {agent.name} is dreaming — moves and deletes wait until the dream has ended.
+
+ ) : working ? (
+
+ {agent.name} is working right now and may still use files here.
+
+ ) : null}
+
+
+
+
+ {tab === "browse" && (
+ setDir(d)}
+ selected={filePath}
+ onSelect={(e) => openFile(e.path, e.size)}
+ frozen={dreaming}
+ actions={{ ...fileActions, onNew: (kind, d) => setNaming({ kind, dir: d }) }}
+ />
+ )}
+ {tab === "notes" && openFile(n.path, n.bytes)} />}
+ {tab === "review" && }
+ {tab === "bin" && }
+
+ {tab !== "bin" && (
+
+ {file && (
+ guard(() => update({ file: null }))} className="flex items-center gap-1.5 border-b px-3 py-2 text-xs text-muted-foreground hover:text-foreground @4xl:hidden">
+ Back to the list
+
+ )}
+
+
+
+
+ )}
+
+
+
+ setDeleting(null)} onDeleted={afterDelete} />
+ setMoving(null)} onMoved={afterMove} />
+ setNaming(null)}
+ onRenamed={(from, to) => afterMove([{ from, to }])}
+ onCreated={(path, kind) => {
+ refresh();
+ if (kind === "note") openFile(path);
+ }}
+ />
+ !o && setPending(null)}>
+
+
+ Discard unsaved changes?
+ Your edits to {filePath} haven't been saved.
+
+
+ Keep editing
+ {
+ setDirty(false);
+ pending?.();
+ setPending(null);
+ }}
+ >
+ Discard
+
+
+
+
+ >
+ );
+}
From 9a9e12a4b5f973d3cef39d8d43931e4badc3e862 Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 04:04:32 +0200
Subject: [PATCH 6/9] Files page: polish from the first look (active rows,
filtered counts, empty folders in the bin)
---
apps/desktop/src/components/files/bin-pane.tsx | 2 +-
apps/desktop/src/components/files/browse-pane.tsx | 4 ++--
apps/desktop/src/components/files/review-pane.tsx | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/components/files/bin-pane.tsx b/apps/desktop/src/components/files/bin-pane.tsx
index 89b89d22..1d9c4be9 100644
--- a/apps/desktop/src/components/files/bin-pane.tsx
+++ b/apps/desktop/src/components/files/bin-pane.tsx
@@ -96,7 +96,7 @@ export function BinPane({ agent, frozen }: { agent: Agent; frozen: boolean }) {
{formatBytes(e.bytes)}
- {e.type === "dir" && ` · ${plural(e.files, "file")}`} · deleted {formatDistanceToNowStrict(new Date(e.deletedAt), { addSuffix: true })} · {left ? `${left} days left` : "goes today"}
+ {e.type === "dir" && (e.files ? ` · ${plural(e.files, "file")}` : " · empty folder")} · deleted {formatDistanceToNowStrict(new Date(e.deletedAt), { addSuffix: true })} · {left ? `${left} days left` : "goes today"}
restore.mutate(e.id)}>
diff --git a/apps/desktop/src/components/files/browse-pane.tsx b/apps/desktop/src/components/files/browse-pane.tsx
index bd2cac13..c813c06d 100644
--- a/apps/desktop/src/components/files/browse-pane.tsx
+++ b/apps/desktop/src/components/files/browse-pane.tsx
@@ -345,8 +345,8 @@ export function BrowsePane({
- {plural(entries.length, "item")}
- {total > 0 && · {formatBytes(total)} }
+ {needle ? `${entries.length} of ${plural(listing.data?.length ?? 0, "item")}` : plural(entries.length, "item")}
+ {total > 0 && !needle && · {formatBytes(total)} }
{sizesQ.isFetching && counting… }
diff --git a/apps/desktop/src/components/files/review-pane.tsx b/apps/desktop/src/components/files/review-pane.tsx
index fa05cc75..6d5e3dec 100644
--- a/apps/desktop/src/components/files/review-pane.tsx
+++ b/apps/desktop/src/components/files/review-pane.tsx
@@ -19,7 +19,7 @@ const KINDS: Record toggle([item.path], v) : undefined}
actions={actions}
From 0d07bfd42e51a767299220dd4f6361eb2bd3de7e Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Tue, 20 Oct 2026 04:06:09 +0200
Subject: [PATCH 7/9] =?UTF-8?q?Files=20page:=20link=20from=20Settings=20?=
=?UTF-8?q?=E2=86=92=20Cleanup,=20screenshots?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/settings/cleanup-section.tsx | 11 ++++++++++-
docs/screenshots/files-page/bin.png | Bin 0 -> 86677 bytes
docs/screenshots/files-page/browse.png | Bin 0 -> 94443 bytes
docs/screenshots/files-page/delete.png | Bin 0 -> 236553 bytes
docs/screenshots/files-page/notes.png | Bin 0 -> 113944 bytes
docs/screenshots/files-page/overview.png | Bin 0 -> 68965 bytes
docs/screenshots/files-page/review-dark.png | Bin 0 -> 128424 bytes
docs/screenshots/files-page/review.png | Bin 0 -> 128193 bytes
docs/screenshots/files-page/split-editor.png | Bin 0 -> 109443 bytes
9 files changed, 10 insertions(+), 1 deletion(-)
create mode 100644 docs/screenshots/files-page/bin.png
create mode 100644 docs/screenshots/files-page/browse.png
create mode 100644 docs/screenshots/files-page/delete.png
create mode 100644 docs/screenshots/files-page/notes.png
create mode 100644 docs/screenshots/files-page/overview.png
create mode 100644 docs/screenshots/files-page/review-dark.png
create mode 100644 docs/screenshots/files-page/review.png
create mode 100644 docs/screenshots/files-page/split-editor.png
diff --git a/apps/desktop/src/components/settings/cleanup-section.tsx b/apps/desktop/src/components/settings/cleanup-section.tsx
index 990029ca..7b39c209 100644
--- a/apps/desktop/src/components/settings/cleanup-section.tsx
+++ b/apps/desktop/src/components/settings/cleanup-section.tsx
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import { motion } from "motion/react";
-import { ArrowDown, ArrowUpRight, BroomSparkles, CalendarClock, CircleAlert, CircleArrowDown, CircleCheck, CircleX, HardDrive, RefreshCw, Stethoscope } from "lucide-react";
+import { ArrowDown, ArrowUpRight, BroomSparkles, CalendarClock, CircleAlert, CircleArrowDown, CircleCheck, CircleX, FolderOpen, HardDrive, RefreshCw, Stethoscope } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import type { CleanupReport, HealthCheck, Settings, StorageArea } from "@godmode/shared";
@@ -62,6 +62,15 @@ export function CleanupSection({ settings }: { settings: Settings | undefined })
)}
{report.data ? : !report.isError && }
+ }>
+
+
+
+ Open Files
+
+
+
+
{settings && }
);
diff --git a/docs/screenshots/files-page/bin.png b/docs/screenshots/files-page/bin.png
new file mode 100644
index 0000000000000000000000000000000000000000..c9dea6f18bfff4046c92ecd1816ad38c05973446
GIT binary patch
literal 86677
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 _k5!c5ON3}o2RF6vr((&nHB`pTYMrRN!xds}
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
z