Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/coding-agent/src/core/subagents/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export interface SelectedBranchRosterSnapshot {
readonly warnings: readonly string[];
}

interface PathDeletionResult {
readonly ok: boolean;
readonly error?: string;
}

interface ParentSessionDeletion<T extends PathDeletionResult> {
readonly parentResult: T;
readonly ownedDirectoryMovedToTrash: boolean;
}

interface ChildCandidate {
readonly name: string;
readonly childSessionId?: string;
Expand Down Expand Up @@ -299,12 +309,64 @@ function assertSafeDirectory(path: string, description: string): void {
throw new Error(`${description} is not a safe directory: ${path}`);
}

function lstatIfPresent(path: string): ReturnType<typeof lstatSync> | undefined {
try {
return lstatSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}

function ensureSafeDirectory(path: string, parentDirectory: string, description: string): void {
assertContainedPath(parentDirectory, path);
if (!existsSync(path)) mkdirSync(path);
assertSafeDirectory(path, description);
}

/** Internal helper for deleting a parent and only its validated, recoverably trashed child namespace. */
export async function deleteParentSessionWithOwnedSubagents<T extends PathDeletionResult>(
parentSessionFile: string,
moveOwnedDirectoryToTrash: (ownedDirectory: string) => Promise<void>,
deleteParentSessionFile: (parentSessionFile: string) => Promise<T>,
): Promise<ParentSessionDeletion<T>> {
const resolvedParentFile = resolve(parentSessionFile);
const header = loadEntriesFromFile(resolvedParentFile)[0];
if (header?.type !== "session") throw new Error(`Session header is unavailable: ${resolvedParentFile}`);
assertValidSessionId(header.id);

const parentDirectory = dirname(resolvedParentFile);
const namespaceDirectory = resolve(parentDirectory, ".subagents");
const ownedDirectory = resolve(namespaceDirectory, header.id);
assertContainedPath(parentDirectory, namespaceDirectory);
assertContainedPath(parentDirectory, ownedDirectory);

let ownedDirectoryMovedToTrash = false;
const namespaceStats = lstatIfPresent(namespaceDirectory);
if (namespaceStats) {
if (namespaceStats.isSymbolicLink() || !namespaceStats.isDirectory()) {
throw new Error(`Subagent namespace is not a safe directory: ${namespaceDirectory}`);
}

const ownedStats = lstatIfPresent(ownedDirectory);
if (ownedStats) {
if (ownedStats.isSymbolicLink() || !ownedStats.isDirectory()) {
throw new Error(`Parent-owned subagent directory is not a safe directory: ${ownedDirectory}`);
}
await moveOwnedDirectoryToTrash(ownedDirectory);
if (lstatIfPresent(ownedDirectory)) {
throw new Error(`Parent-owned subagent directory remains after trash operation: ${ownedDirectory}`);
}
ownedDirectoryMovedToTrash = true;
}
}

return {
parentResult: await deleteParentSessionFile(resolvedParentFile),
ownedDirectoryMovedToTrash,
};
}

export class ParentOwnedSubagentSessionStore {
readonly #parent: SessionManager;
readonly #parentDirectory: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "@earendil-works/diffex-tui";
import { KeybindingsManager } from "../../../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts";
import { deleteParentSessionWithOwnedSubagents } from "../../../core/subagents/persistence.ts";
import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
Expand Down Expand Up @@ -641,31 +642,29 @@ class SessionList implements Component, Focusable {

type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;

/**
* Delete a session file, trying the `trash` CLI first, then falling back to unlink
*/
async function deleteSessionFile(
sessionPath: string,
): Promise<{ ok: boolean; method: "trash" | "unlink"; error?: string }> {
// Try `trash` first (if installed)
const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
type SessionFileDeletionResult = { ok: boolean; method: "trash" | "unlink"; error?: string };

const getTrashErrorHint = (): string | null => {
const parts: string[] = [];
if (trashResult.error) {
parts.push(trashResult.error.message);
}
const stderr = trashResult.stderr?.trim();
if (stderr) {
parts.push(stderr.split("\n")[0] ?? stderr);
}
if (parts.length === 0) return null;
return `trash: ${parts.join(" · ").slice(0, 200)}`;
function movePathToTrash(path: string): { ok: boolean; error?: string } {
const trashArgs = path.startsWith("-") ? ["--", path] : [path];
const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
const parts: string[] = [];
if (trashResult.error) parts.push(trashResult.error.message);
const stderr = trashResult.stderr?.trim();
if (stderr) parts.push(stderr.split("\n")[0] ?? stderr);

if (trashResult.status === 0 || !existsSync(path)) return { ok: true };
return {
ok: false,
...(parts.length === 0 ? {} : { error: `trash: ${parts.join(" · ").slice(0, 200)}` }),
};
}

// If trash reports success, or the file is gone afterwards, treat it as successful
if (trashResult.status === 0 || !existsSync(sessionPath)) {
/**
* Delete a session file, trying the `trash` CLI first, then falling back to unlink
*/
async function deleteSessionFile(sessionPath: string): Promise<SessionFileDeletionResult> {
const trashResult = movePathToTrash(sessionPath);
if (trashResult.ok) {
return { ok: true, method: "trash" };
}

Expand All @@ -675,12 +674,58 @@ async function deleteSessionFile(
return { ok: true, method: "unlink" };
} catch (err) {
const unlinkError = err instanceof Error ? err.message : String(err);
const trashErrorHint = getTrashErrorHint();
const error = trashErrorHint ? `${unlinkError} (${trashErrorHint})` : unlinkError;
const error = trashResult.error ? `${unlinkError} (${trashResult.error})` : unlinkError;
return { ok: false, method: "unlink", error };
}
}

type MoveOwnedDirectoryToTrash = (ownedDirectory: string) => Promise<void>;
type DeleteParentSessionFile = (parentSessionFile: string) => Promise<SessionFileDeletionResult>;

/** Internal selector deletion seam used by focused tests. */
export async function deleteSessionWithOwnedSubagents(
sessionPath: string,
moveOwnedDirectoryToTrash: MoveOwnedDirectoryToTrash,
deleteParentSessionFile: DeleteParentSessionFile,
): Promise<SessionFileDeletionResult> {
try {
const deletion = await deleteParentSessionWithOwnedSubagents(
sessionPath,
moveOwnedDirectoryToTrash,
async (parentSessionFile): Promise<SessionFileDeletionResult> => {
try {
return await deleteParentSessionFile(parentSessionFile);
} catch (error) {
return {
ok: false,
method: "trash",
error: error instanceof Error ? error.message : String(error),
};
}
},
);
if (!deletion.parentResult.ok && deletion.ownedDirectoryMovedToTrash) {
return {
...deletion.parentResult,
error: `Parent-owned subagent sessions were moved to trash, but parent deletion failed: ${deletion.parentResult.error ?? "unknown error"}`,
};
}
return deletion.parentResult;
} catch (error) {
return {
ok: false,
method: "trash",
error: `Parent-owned subagent cleanup failed; parent session was retained: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

async function moveOwnedSubagentDirectoryToTrash(ownedDirectory: string): Promise<void> {
const result = movePathToTrash(ownedDirectory);
if (!result.ok) throw new Error(result.error ?? "trash operation failed");
if (existsSync(ownedDirectory)) throw new Error("trash reported success but the directory remains");
}

/**
* Component that renders a session selector
*/
Expand Down Expand Up @@ -832,7 +877,11 @@ export class SessionSelectorComponent extends Container implements Focusable {

// Handle session deletion
this.sessionList.onDeleteSession = async (sessionPath: string) => {
const result = await deleteSessionFile(sessionPath);
const result = await deleteSessionWithOwnedSubagents(
sessionPath,
moveOwnedSubagentDirectoryToTrash,
deleteSessionFile,
);

if (result.ok) {
if (this.currentSessions) {
Expand Down
Loading
Loading