diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 21cf02c0..0702ab4a 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -1,5 +1,7 @@ import { existsSync } from "node:fs"; +import { join } from "node:path"; import type { SelectWorktree } from "@superset/local-db"; +import { getAgentHome, getAgentWorktreePath } from "main/lib/agent-home"; import { track } from "main/lib/analytics"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; @@ -22,7 +24,11 @@ import { hasUnpushedCommits, worktreeExists, } from "../utils/git"; -import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; +import { + removeAgentHomeFromDisk, + removeWorktreeFromDisk, + runTeardown, +} from "../utils/teardown"; export const createDeleteProcedures = () => { return router({ @@ -44,6 +50,7 @@ export const createDeleteProcedures = () => { activeTerminalCount: 0, hasChanges: false, hasUnpushedCommits: false, + isAgent: false, }; } @@ -55,6 +62,7 @@ export const createDeleteProcedures = () => { activeTerminalCount: 0, hasChanges: false, hasUnpushedCommits: false, + isAgent: false, }; } @@ -71,6 +79,10 @@ export const createDeleteProcedures = () => { activeTerminalCount, hasChanges: false, hasUnpushedCommits: false, + isAgent: + !!workspace.worktreeId && + getWorktree(workspace.worktreeId)?.path === + getAgentWorktreePath(workspace.id), }; } @@ -83,6 +95,10 @@ export const createDeleteProcedures = () => { activeTerminalCount, hasChanges: false, hasUnpushedCommits: false, + isAgent: + !!workspace.worktreeId && + getWorktree(workspace.worktreeId)?.path === + getAgentWorktreePath(workspace.id), }; } @@ -92,22 +108,27 @@ export const createDeleteProcedures = () => { const project = getProject(workspace.projectId); if (worktree && project) { + // Papyrus Agents own a standalone clone at /worktree; + // it is NOT a `git worktree` of the Category's mainRepoPath, so + // check the clone directly instead of `git worktree list`. + const isAgent = worktree.path === getAgentWorktreePath(workspace.id); try { - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, - ); + const exists = isAgent + ? existsSync(join(worktree.path, ".git")) + : await worktreeExists(project.mainRepoPath, worktree.path); if (!exists) { return { canDelete: true, reason: null, workspace, - warning: - "Worktree not found in git (may have been manually removed)", + warning: isAgent + ? "Agent repo not found on disk" + : "Worktree not found in git (may have been manually removed)", activeTerminalCount, hasChanges: false, hasUnpushedCommits: false, + isAgent, }; } @@ -124,6 +145,7 @@ export const createDeleteProcedures = () => { activeTerminalCount, hasChanges, hasUnpushedCommits: unpushedCommits, + isAgent, }; } catch (error) { return { @@ -133,6 +155,7 @@ export const createDeleteProcedures = () => { activeTerminalCount, hasChanges: false, hasUnpushedCommits: false, + isAgent, }; } } @@ -145,6 +168,7 @@ export const createDeleteProcedures = () => { activeTerminalCount, hasChanges: false, hasUnpushedCommits: false, + isAgent: false, }; }), @@ -248,14 +272,22 @@ export const createDeleteProcedures = () => { } } + // Agents own a standalone clone under their home dir — remove the + // whole home (worktree + memory + .codex) rather than `git worktree + // remove` against the Category's mainRepoPath (not a repo). + const isAgentRepo = + !!worktree && worktree.path === getAgentWorktreePath(input.id); + if (worktree && project) { await workspaceInitManager.acquireProjectLock(project.id); try { - const removeResult = await removeWorktreeFromDisk({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - }); + const removeResult = isAgentRepo + ? await removeAgentHomeFromDisk(getAgentHome(input.id)) + : await removeWorktreeFromDisk({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + }); if (!removeResult.success) { clearWorkspaceDeletingStatus(input.id); return removeResult; @@ -264,7 +296,7 @@ export const createDeleteProcedures = () => { workspaceInitManager.releaseProjectLock(project.id); } - if (input.deleteLocalBranch && workspace.branch) { + if (!isAgentRepo && input.deleteLocalBranch && workspace.branch) { try { await deleteLocalBranch({ mainRepoPath: project.mainRepoPath, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts index 0cbfd6db..4110b572 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts @@ -1,4 +1,8 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { rename } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { getCommandShellArgs, getShellEnv, @@ -129,6 +133,42 @@ export async function runTeardown({ } } +/** + * Papyrus: delete an Agent's entire home dir (worktree + memory + .codex). + * Agents own a standalone clone, not a `git worktree` of a main repo, so + * there is no worktree metadata to prune — just remove the directory. + * Same rename-then-background-rm pattern as removeWorktree in git.ts. + */ +export async function removeAgentHomeFromDisk( + agentHome: string, +): Promise<{ success: true } | { success: false; error: string }> { + try { + if (!existsSync(agentHome)) { + return { success: true }; + } + const tempPath = join( + dirname(agentHome), + `.papyrus-delete-${randomUUID()}`, + ); + await rename(agentHome, tempPath); + const child = spawn("/bin/rm", ["-rf", tempPath], { + detached: true, + stdio: "ignore", + }); + child.on("error", (err: Error) => { + console.error( + `[removeAgentHome] Failed to spawn rm for ${tempPath}:`, + err.message, + ); + }); + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error("Failed to remove agent home:", msg); + return { success: false, error: `Failed to remove agent files: ${msg}` }; + } +} + export async function removeWorktreeFromDisk({ mainRepoPath, worktreePath, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e04a03c6..fbad26fe 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,3 +1,8 @@ +// MUST be the first import: registers local-db host hooks before any module +// (e.g. server-core's agent-memory-backfill → ./local-db) opens the DB and +// runs migrations at module scope. Relying on the ./lib/local-db shim is not +// enough — server-core modules import local-db relatively, bypassing the shim. +import "./lib/local-db/register-host-hooks"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { settings } from "@superset/local-db"; @@ -152,19 +157,24 @@ export function setSkipQuitConfirmation(): void { export function quitWithoutConfirmation(): void { skipConfirmation = true; - app.exit(0); + // Route through app.quit() so the before-quit teardown runs; + // app.exit() would bypass it. + app.quit(); } app.on("before-quit", async (event) => { if (isQuitting) return; + // Always preventDefault: this handler is async, and without it Electron + // exits before the teardown below runs on the non-confirm path. The + // app.exit(0) at the end performs the real quit. + event.preventDefault(); + const isDev = process.env.NODE_ENV === "development"; const shouldConfirm = !skipConfirmation && !isDev && getConfirmOnQuitSetting(); if (shouldConfirm) { - event.preventDefault(); - try { const { response } = await dialog.showMessageBox({ type: "question", diff --git a/apps/desktop/src/renderer/components/NewAgentModal/NewAgentModal.tsx b/apps/desktop/src/renderer/components/NewAgentModal/NewAgentModal.tsx index 5a1e14fe..fd74cbb2 100644 --- a/apps/desktop/src/renderer/components/NewAgentModal/NewAgentModal.tsx +++ b/apps/desktop/src/renderer/components/NewAgentModal/NewAgentModal.tsx @@ -199,7 +199,7 @@ export function NewAgentModal() { open={isOpen} onOpenChange={(open) => !open && closeModal()} > - + New agent diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx index c29486c8..92f5ed30 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx @@ -123,6 +123,7 @@ export function DeleteWorkspaceDialog({ }; const canDelete = canDeleteData?.canDelete ?? true; + const isAgent = canDeleteData?.isAgent ?? false; const reason = canDeleteData?.reason; const hasChanges = canDeleteData?.hasChanges ?? false; const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; @@ -196,8 +197,9 @@ export function DeleteWorkspaceDialog({ {reason} ) : ( - Deleting will permanently remove the worktree. You can hide - instead to keep files on disk. + {isAgent + ? "Deleting will permanently remove the agent's repo and memory from disk. You can hide instead to keep files on disk." + : "Deleting will permanently remove the worktree. You can hide instead to keep files on disk."} )} @@ -216,7 +218,7 @@ export function DeleteWorkspaceDialog({ )} - {!isLoading && canDelete && ( + {!isLoading && canDelete && !isAgent && (
- Permanently delete agent and git worktree from disk. + {isAgent + ? "Permanently delete the agent, its repo, and its memory from disk." + : "Permanently delete agent and git worktree from disk."} diff --git a/apps/desktop/src/resources/build/icons/icon.icns b/apps/desktop/src/resources/build/icons/icon.icns index f697bce9..974f118f 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.icns and b/apps/desktop/src/resources/build/icons/icon.icns differ diff --git a/apps/desktop/src/resources/build/icons/icon.ico b/apps/desktop/src/resources/build/icons/icon.ico index e3dd6fa7..c6433750 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.ico and b/apps/desktop/src/resources/build/icons/icon.ico differ diff --git a/apps/desktop/src/resources/build/icons/icon.png b/apps/desktop/src/resources/build/icons/icon.png index 27bab23b..686951bc 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.png and b/apps/desktop/src/resources/build/icons/icon.png differ diff --git a/packages/server-core/src/github-repos.ts b/packages/server-core/src/github-repos.ts index 6595a20a..d9ec8471 100644 --- a/packages/server-core/src/github-repos.ts +++ b/packages/server-core/src/github-repos.ts @@ -19,6 +19,14 @@ const GHRepoListItemSchema = z.object({ export type GitHubRepoListItem = z.infer; +// REST /user/repos item shape (only the fields the picker needs). +const GHApiRepoSchema = z.object({ + full_name: z.string(), + html_url: z.string(), + description: z.string().nullable(), + updated_at: z.string(), +}); + export interface ListGitHubReposResult { repos: GitHubRepoListItem[]; authenticated: boolean; @@ -27,12 +35,53 @@ export interface ListGitHubReposResult { const REPO_LIST_LIMIT = 200; /** - * Lists the authenticated user's GitHub repos via `gh repo list`. Never - * throws — `gh` missing, not authenticated, or any other failure (network, - * malformed output) all degrade to `{ repos: [], authenticated: false }` so - * callers can render a "run `gh auth login`" hint instead of erroring. + * Lists the authenticated user's GitHub repos — personal AND org/collaborator + * repos — via the REST `/user/repos` endpoint (`gh repo list` only returns + * repos the user owns, which hides organization repos; see the affiliation + * param). `--paginate --slurp` yields a JSON array of pages. Never throws — + * `gh` missing, not authenticated, or any other failure (network, malformed + * output) all degrade to `{ repos: [], authenticated: false }` so callers can + * render a "run `gh auth login`" hint instead of erroring. */ export async function listGitHubRepos(): Promise { + try { + const { stdout } = await execWithShellEnv("gh", [ + "api", + "--paginate", + "--slurp", + "user/repos?affiliation=owner,collaborator,organization_member&sort=updated&per_page=100", + ]); + + const raw: unknown = JSON.parse(stdout); + if (!Array.isArray(raw)) { + return { repos: [], authenticated: false }; + } + + const repos: GitHubRepoListItem[] = []; + for (const page of raw) { + if (!Array.isArray(page)) continue; + for (const item of page) { + const result = GHApiRepoSchema.safeParse(item); + if (result.success) { + repos.push({ + nameWithOwner: result.data.full_name, + url: result.data.html_url, + description: result.data.description, + updatedAt: result.data.updated_at, + }); + } + } + } + + return { repos: repos.slice(0, REPO_LIST_LIMIT), authenticated: true }; + } catch { + // `gh api --slurp` needs a reasonably recent gh; fall back to the + // owner-only listing rather than losing the picker entirely. + return listOwnedGitHubRepos(); + } +} + +async function listOwnedGitHubRepos(): Promise { try { const { stdout } = await execWithShellEnv("gh", [ "repo", diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 42196637..8175ae3f 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -61,7 +61,7 @@ function DialogContent({