diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index fda62a4a3..f23c59b04 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -335,6 +335,7 @@ export const ar: TranslationResources = {
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
hostStatus: "حالة Host:{{status}}",
missing: "لم يتم العثور على Workspace",
+ needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
manageHost: "إدارة المضيف",
},
hoverCard: {
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 2e9cb0a09..fa8946b91 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -335,6 +335,7 @@ export const en = {
cannotReachHost: "Cannot reach {{hostName}}",
hostStatus: "Host status: {{status}}",
missing: "Workspace not found",
+ needsHostUpgrade: "Update your host to restore this workspace",
manageHost: "Manage host",
},
hoverCard: {
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 603abd8d8..2a17dd216 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -339,6 +339,7 @@ export const es: TranslationResources = {
cannotReachHost: "No se puede alcanzar{{hostName}}",
hostStatus: "Estado deHost:{{status}}",
missing: "Workspaceno encontrado",
+ needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
manageHost: "Administrar host",
},
hoverCard: {
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index c69b51d1e..fdbf7880d 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -339,6 +339,7 @@ export const fr: TranslationResources = {
cannotReachHost: "Impossible d'atteindre{{hostName}}",
hostStatus: "StatutHost:{{status}}",
missing: "Workspaceintrouvable",
+ needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
manageHost: "Gérer l'hôte",
},
hoverCard: {
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 40312d252..f8be82659 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -338,6 +338,7 @@ export const ru: TranslationResources = {
cannotReachHost: "Невозможно связаться с{{hostName}}",
hostStatus: "Статус Host:{{status}}",
missing: "Workspace не найден",
+ needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
manageHost: "Управление хостом",
},
hoverCard: {
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index d79614a15..de1b40d75 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -335,6 +335,7 @@ export const zhCN: TranslationResources = {
cannotReachHost: "无法连接 {{hostName}}",
hostStatus: "Host 状态:{{status}}",
missing: "Workspace 未找到",
+ needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
manageHost: "管理 Host",
},
hoverCard: {
diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx
index ba40dad12..a213e383b 100644
--- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx
+++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx
@@ -23,6 +23,14 @@ export function renderWorkspaceRouteGate(input: {
return ;
case "restoring":
return ;
+ case "needsHostUpgrade":
+ return (
+
+ );
case "unreachable":
return (
);
@@ -142,13 +152,16 @@ function WorkspaceUnreachable({
);
}
-function WorkspaceMissing({
+function WorkspaceEmptyState({
+ titleKey,
hostName,
- restoreFailed,
onDismiss,
}: {
+ titleKey:
+ | "workspace.route.missing"
+ | "workspace.route.restoreFailed"
+ | "workspace.route.needsHostUpgrade";
hostName: string;
- restoreFailed: boolean;
onDismiss: () => void;
}) {
const { t } = useTranslation();
@@ -156,9 +169,7 @@ function WorkspaceMissing({
return (
-
- {restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
-
+ {t(titleKey)}
{hostName}
diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts
index 48d893959..34e858c4c 100644
--- a/packages/app/src/screens/workspace/workspace-route-state.test.ts
+++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts
@@ -127,6 +127,19 @@ describe("resolveWorkspaceRouteState", () => {
).toEqual({ kind: "restoring", hostName: "Laptop" });
});
+ it("returns needsHostUpgrade when the daemon lacks the restore capability", () => {
+ expect(
+ resolveWorkspaceRouteState({
+ hostName: "Laptop",
+ connectionStatus: "online",
+ lastError: null,
+ workspace: null,
+ hasHydratedWorkspaces: true,
+ restoreStatus: "needs-host-upgrade",
+ }),
+ ).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" });
+ });
+
it("falls back to a restore-failed missing state once the restore times out", () => {
expect(
resolveWorkspaceRouteState({
diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts
index 0f689b2bd..1a9540c56 100644
--- a/packages/app/src/screens/workspace/workspace-route-state.ts
+++ b/packages/app/src/screens/workspace/workspace-route-state.ts
@@ -17,6 +17,7 @@ export type WorkspaceRouteState =
}
| { kind: "loading"; hostName: string }
| { kind: "restoring"; hostName: string }
+ | { kind: "needsHostUpgrade"; hostName: string }
| { kind: "missing"; hostName: string; restoreFailed: boolean };
export function resolveWorkspaceRouteState(input: {
@@ -25,7 +26,7 @@ export function resolveWorkspaceRouteState(input: {
lastError: string | null;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
- restoreStatus: "restoring" | "failed" | null;
+ restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
}): WorkspaceRouteState {
if (input.workspace) {
if (input.connectionStatus === "online") {
@@ -45,6 +46,10 @@ export function resolveWorkspaceRouteState(input: {
return { kind: "restoring", hostName: input.hostName };
}
+ if (input.restoreStatus === "needs-host-upgrade") {
+ return { kind: "needsHostUpgrade", hostName: input.hostName };
+ }
+
if (input.hasHydratedWorkspaces) {
return {
kind: "missing",
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index a33b80792..5e5e00cdb 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -287,7 +287,7 @@ export interface AgentTimelineCursorState {
endSeq: number;
}
-export type WorkspaceRestoreStatus = "restoring" | "failed";
+export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
// Per-session state
export interface SessionState {
diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts
index e2a0b9233..5a2de775c 100644
--- a/packages/app/src/utils/navigate-to-agent/index.ts
+++ b/packages/app/src/utils/navigate-to-agent/index.ts
@@ -7,8 +7,10 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export type { NavigateToAgentInput } from "./resolve";
// Clears the transient restoring state if the daemon resolves refreshAgent without
-// re-emitting a workspace_update (the directory-gone case), so the gate never spins forever.
-const RESTORE_TIMEOUT_MS = 7000;
+// re-emitting a workspace_update (the directory-gone case), so the gate never spins
+// forever. Recreating a worktree can require a git fetch, so the budget is generous
+// to avoid flashing a false "failed" on a capable daemon doing slow real work.
+const RESTORE_TIMEOUT_MS = 30000;
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
@@ -33,6 +35,15 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
return;
}
+ // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
+ // Single capability read for restore. An old daemon recreates nothing on
+ // refresh_agent, so a gone directory would spin then flash a misleading
+ // "couldn't restore". Surface an explicit "update your host" state instead.
+ if (session?.serverInfo?.features?.worktreeRestore !== true) {
+ store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
+ return;
+ }
+
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
setTimeout(
diff --git a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts
index 098327165..1f5a5e911 100644
--- a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts
+++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts
@@ -27,15 +27,21 @@ const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
const WORKSPACE_ID = "workspace-1";
-function status(): "restoring" | "failed" | null {
+function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
return (
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
);
}
-function seedArchivedAgent(): void {
+function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
+ store.updateSessionServerInfo(SERVER_ID, {
+ serverId: SERVER_ID,
+ hostname: "host",
+ version: "0.1.98",
+ features: { worktreeRestore: options?.worktreeRestore ?? true },
+ } as unknown as Parameters[1]);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, {
@@ -133,10 +139,21 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
await Promise.resolve();
expect(status()).toBe("restoring");
- await vi.advanceTimersByTimeAsync(7000);
+ await vi.advanceTimersByTimeAsync(30000);
expect(status()).toBe("failed");
});
+ it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
+ useSessionStore.getState().clearSession(SERVER_ID);
+ seedArchivedAgent({ worktreeRestore: false });
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+
+ trigger();
+
+ expect(refreshAgent).not.toHaveBeenCalled();
+ expect(status()).toBe("needs-host-upgrade");
+ });
+
it("is a no-op when the workspace descriptor is already present", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index b05f44746..ef21d804a 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -2256,6 +2256,8 @@ export const ServerInfoStatusPayloadSchema = z
checkoutRefresh: z.boolean().optional(),
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
workspaceMultiplicity: z.boolean().optional(),
+ // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
+ worktreeRestore: z.boolean().optional(),
})
.optional(),
})
diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts
index e1ffbbd98..e7953d8a1 100644
--- a/packages/server/src/server/paseo-worktree-service.test.ts
+++ b/packages/server/src/server/paseo-worktree-service.test.ts
@@ -18,7 +18,9 @@ import {
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
+import { createWorktree } from "../utils/worktree.js";
import { isPlatform } from "../test-utils/platform.js";
+import { existsSync } from "node:fs";
const cleanupPaths: string[] = [];
@@ -503,6 +505,109 @@ test("does not mutate registries or broadcast when core worktree creation fails"
expect(deps.workspaces.size).toBe(0);
});
+// Worktree restore (Unit 3): recreate a deleted Paseo-owned worktree from its
+// kept branch via createWorktree's checkout-branch source.
+test.skipIf(isPlatform("win32"))(
+ "recreates a deleted worktree on the same kept branch without creating a suffixed branch",
+ async () => {
+ const { repoDir, tempDir } = createGitRepo();
+ cleanupPaths.push(tempDir);
+ const paseoHome = path.join(tempDir, ".paseo");
+
+ execFileSync("git", ["branch", "restore-me"], { cwd: repoDir, stdio: "pipe" });
+
+ const created = await createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "restore-me",
+ source: { kind: "checkout-branch", branchName: "restore-me" },
+ runSetup: false,
+ paseoHome,
+ });
+ expect(existsSync(created.worktreePath)).toBe(true);
+
+ execFileSync("git", ["worktree", "remove", created.worktreePath, "--force"], {
+ cwd: repoDir,
+ stdio: "pipe",
+ });
+ expect(existsSync(created.worktreePath)).toBe(false);
+
+ const recreated = await createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "restore-me",
+ source: { kind: "checkout-branch", branchName: "restore-me" },
+ runSetup: false,
+ paseoHome,
+ });
+
+ expect(recreated.worktreePath).toBe(created.worktreePath);
+ expect(existsSync(recreated.worktreePath)).toBe(true);
+ expect(
+ execFileSync("git", ["branch", "--show-current"], {
+ cwd: recreated.worktreePath,
+ stdio: "pipe",
+ })
+ .toString()
+ .trim(),
+ ).toBe("restore-me");
+ const branches = execFileSync("git", ["branch", "--list", "restore-me*"], {
+ cwd: repoDir,
+ stdio: "pipe",
+ })
+ .toString()
+ .split("\n")
+ .map((line) => line.replace(/^[*+ ]+/, "").trim())
+ .filter(Boolean);
+ expect(branches).toEqual(["restore-me"]);
+ },
+);
+
+test.skipIf(isPlatform("win32"))(
+ "rejects with UnknownBranchError when the kept branch no longer exists",
+ async () => {
+ const { repoDir, tempDir } = createGitRepo();
+ cleanupPaths.push(tempDir);
+
+ await expect(
+ createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "gone-branch",
+ source: { kind: "checkout-branch", branchName: "gone-branch" },
+ runSetup: false,
+ paseoHome: path.join(tempDir, ".paseo"),
+ }),
+ ).rejects.toMatchObject({ name: "UnknownBranchError" });
+ },
+);
+
+test.skipIf(isPlatform("win32"))(
+ "rejects with BranchAlreadyCheckedOutError when the kept branch is checked out elsewhere",
+ async () => {
+ const { repoDir, tempDir } = createGitRepo();
+ cleanupPaths.push(tempDir);
+ const paseoHome = path.join(tempDir, ".paseo");
+
+ execFileSync("git", ["branch", "busy-branch"], { cwd: repoDir, stdio: "pipe" });
+ const first = await createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "busy-branch",
+ source: { kind: "checkout-branch", branchName: "busy-branch" },
+ runSetup: false,
+ paseoHome,
+ });
+ expect(existsSync(first.worktreePath)).toBe(true);
+
+ await expect(
+ createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "busy-branch-again",
+ source: { kind: "checkout-branch", branchName: "busy-branch" },
+ runSetup: false,
+ paseoHome,
+ }),
+ ).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" });
+ },
+);
+
interface TestDeps extends CreatePaseoWorktreeDeps {
projectRegistry: Pick;
projects: Map;
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 827922dc7..3cfc49e9d 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { stat } from "node:fs/promises";
-import { resolve, sep } from "path";
+import { basename, normalize, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
@@ -271,7 +271,12 @@ import {
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
-import { toWorktreeWireError } from "./worktree-errors.js";
+import {
+ WorktreeRequestError,
+ toWorktreeRequestError,
+ toWorktreeWireError,
+} from "./worktree-errors.js";
+import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -3555,7 +3560,7 @@ export class Session {
requestId,
requestType: msg.type,
error: message,
- code: "agent_refresh_failed",
+ code: error instanceof WorktreeRequestError ? error.code : "agent_refresh_failed",
},
});
}
@@ -7072,18 +7077,69 @@ export class Session {
if (!record?.workspaceId) {
return;
}
- const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
- if (!directoryExists) {
- return;
- }
const workspace = await this.workspaceRegistry.get(record.workspaceId);
if (!workspace?.archivedAt) {
return;
}
+
+ const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
+ if (!directoryExists) {
+ if (workspace.kind !== "worktree" || !workspace.branch) {
+ return;
+ }
+ // Recreate the worktree directory from its kept branch BEFORE clearing
+ // archivedAt — the reconciler re-archives workspaces whose directory is
+ // missing, so the record must point at a real directory first.
+ await this.recreateOwningWorktreeForRestore(workspace, workspace.branch);
+ }
+
await this.ensureWorkspaceRecordUnarchived(workspace);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
}
+ private async recreateOwningWorktreeForRestore(
+ workspace: PersistedWorkspaceRecord,
+ branch: string,
+ ): Promise {
+ const project = await this.projectRegistry.get(workspace.projectId);
+ if (!project) {
+ throw new WorktreeRequestError({
+ code: "unknown",
+ message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`,
+ });
+ }
+ const projectRootExists = await this.filesystem
+ .isDirectory(project.rootPath)
+ .catch(() => false);
+ if (!projectRootExists) {
+ throw new WorktreeRequestError({
+ code: "unknown",
+ message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`,
+ });
+ }
+
+ let result: WorktreeConfig;
+ try {
+ result = await createWorktree({
+ cwd: project.rootPath,
+ worktreeSlug: basename(workspace.cwd),
+ source: { kind: "checkout-branch", branchName: branch },
+ runSetup: false,
+ paseoHome: this.paseoHome,
+ worktreesRoot: this.worktreesRoot,
+ });
+ } catch (error) {
+ throw toWorktreeRequestError(error);
+ }
+
+ if (normalize(result.worktreePath) !== normalize(workspace.cwd)) {
+ throw new WorktreeRequestError({
+ code: "unknown",
+ message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`,
+ });
+ }
+ }
+
private async ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise {
diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts
index d93515d10..14be43d2f 100644
--- a/packages/server/src/server/session.workspaces.test.ts
+++ b/packages/server/src/server/session.workspaces.test.ts
@@ -1,5 +1,13 @@
import { execFileSync } from "node:child_process";
-import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
@@ -22,7 +30,8 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
-import { createWorktree } from "../utils/worktree.js";
+import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
+import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import type { GitHubService } from "../services/github-service.js";
@@ -106,6 +115,10 @@ interface SessionTestAccess {
agentUpdatesSubscription: unknown;
workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown;
+ recreateOwningWorktreeForRestore(
+ workspace: PersistedWorkspaceRecord,
+ branch: string,
+ ): Promise;
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise>;
reconcileWorkspaceRecord(workspaceId: string): Promise<{
changed: boolean;
@@ -4232,6 +4245,411 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
});
+test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", async () => {
+ const emitted: SessionOutboundMessage[] = [];
+ const session = createSessionForWorkspaceTests({
+ onMessage: (message) => {
+ if (isSessionOutboundMessage(message)) emitted.push(message);
+ },
+ });
+ const projects = new Map>();
+ const workspaces = new Map>();
+
+ const cwd = path.resolve("/tmp/paseo-deleted-worktree-dir");
+ session.filesystem.isDirectory = async () => false;
+ const workspaceId = "ws-deleted-worktree";
+ const agentId = "agent-deleted-worktree";
+ projects.set(
+ cwd,
+ createPersistedProjectRecord({
+ projectId: cwd,
+ rootPath: cwd,
+ kind: "git",
+ displayName: "worktree-project",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+ workspaces.set(
+ workspaceId,
+ createPersistedWorkspaceRecord({
+ workspaceId,
+ projectId: cwd,
+ cwd,
+ kind: "worktree",
+ branch: "feature/keep",
+ displayName: "feature/keep",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+
+ const storedAgent: StoredAgentRecord = {
+ ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
+ workspaceId,
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ };
+
+ session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
+ session.projectRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ projects.set(record.projectId, record);
+ };
+ session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
+ workspaces.get(lookupWorkspaceId) ?? null;
+ session.workspaceRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ workspaces.set(record.workspaceId, record);
+ };
+ session.projectRegistry.list = async () => Array.from(projects.values());
+ session.workspaceRegistry.list = async () => Array.from(workspaces.values());
+
+ session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
+ session.agentStorage.upsert = async () => {};
+
+ const recreateCalls: string[] = [];
+ session.recreateOwningWorktreeForRestore = async (
+ workspace: PersistedWorkspaceRecord,
+ ): Promise => {
+ recreateCalls.push(workspace.workspaceId);
+ };
+
+ const managed = makeManagedAgent({
+ id: agentId,
+ cwd,
+ workspaceId,
+ lifecycle: "idle",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ });
+ session.agentManager.getAgent = () => managed;
+ session.interruptAgentIfRunning = async () => undefined;
+ session.agentManager.reloadAgentSession = async () => managed;
+ session.agentManager.hydrateTimelineFromProvider = async () => undefined;
+ session.agentManager.getTimeline = () => [];
+ session.forwardAgentUpdate = async () => undefined;
+
+ const unarchivedWorkspaceIds: string[][] = [];
+ const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
+ session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
+ unarchivedWorkspaceIds.push(ids);
+ return realEmit(ids, ...rest);
+ };
+
+ await session.handleMessage({
+ type: "refresh_agent_request",
+ agentId,
+ requestId: "req-refresh-recreate-worktree",
+ });
+
+ expect(recreateCalls).toEqual([workspaceId]);
+ expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
+ expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
+ expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
+ expect(findByType(emitted, "rpc_error")).toBeUndefined();
+});
+
+test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", async () => {
+ const emitted: SessionOutboundMessage[] = [];
+ const session = createSessionForWorkspaceTests({
+ onMessage: (message) => {
+ if (isSessionOutboundMessage(message)) emitted.push(message);
+ },
+ });
+ const projects = new Map>();
+ const workspaces = new Map>();
+
+ const cwd = path.resolve("/tmp/paseo-deleted-worktree-fail");
+ session.filesystem.isDirectory = async () => false;
+ const workspaceId = "ws-deleted-worktree-fail";
+ const agentId = "agent-deleted-worktree-fail";
+ projects.set(
+ cwd,
+ createPersistedProjectRecord({
+ projectId: cwd,
+ rootPath: cwd,
+ kind: "git",
+ displayName: "worktree-project",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+ workspaces.set(
+ workspaceId,
+ createPersistedWorkspaceRecord({
+ workspaceId,
+ projectId: cwd,
+ cwd,
+ kind: "worktree",
+ branch: "feature/gone",
+ displayName: "feature/gone",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+
+ const storedAgent: StoredAgentRecord = {
+ ...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
+ workspaceId,
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ };
+
+ session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
+ session.projectRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ projects.set(record.projectId, record);
+ };
+ session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
+ workspaces.get(lookupWorkspaceId) ?? null;
+ session.workspaceRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ workspaces.set(record.workspaceId, record);
+ };
+ session.projectRegistry.list = async () => Array.from(projects.values());
+ session.workspaceRegistry.list = async () => Array.from(workspaces.values());
+
+ session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
+ session.agentStorage.upsert = async () => {};
+
+ session.recreateOwningWorktreeForRestore = async (): Promise => {
+ throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
+ };
+
+ const managed = makeManagedAgent({
+ id: agentId,
+ cwd,
+ workspaceId,
+ lifecycle: "idle",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ });
+ session.agentManager.getAgent = () => managed;
+ session.interruptAgentIfRunning = async () => undefined;
+ session.agentManager.reloadAgentSession = async () => managed;
+ session.agentManager.hydrateTimelineFromProvider = async () => undefined;
+ session.agentManager.getTimeline = () => [];
+ session.forwardAgentUpdate = async () => undefined;
+
+ await session.handleMessage({
+ type: "refresh_agent_request",
+ agentId,
+ requestId: "req-refresh-recreate-fail",
+ });
+
+ expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
+ const rpcError = findByType(emitted, "rpc_error");
+ expect(rpcError).toBeDefined();
+ expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
+});
+
+function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
+ const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-recreate-worktree-")));
+ const repoDir = path.join(tempDir, "repo");
+ execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
+ execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
+ cwd: repoDir,
+ stdio: "pipe",
+ });
+ execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
+ execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
+ writeFileSync(path.join(repoDir, "README.md"), "main\n");
+ execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
+ execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
+ return { tempDir, repoDir };
+}
+
+test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => {
+ const { tempDir, repoDir } = createRecreateWorktreeRepo();
+ const branch = "feature/keep";
+ execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
+
+ const worktreesRoot = path.join(tempDir, "worktrees");
+ const paseoHome = path.join(tempDir, "paseo-home");
+ const created = await createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "keep",
+ source: { kind: "checkout-branch", branchName: branch },
+ runSetup: false,
+ paseoHome,
+ worktreesRoot,
+ });
+ const worktreePath = realpathSync(created.worktreePath);
+ // Simulate archive: drop the worktree dir but keep the branch.
+ rmSync(worktreePath, { recursive: true, force: true });
+ execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
+ expect(existsSync(worktreePath)).toBe(false);
+
+ const emitted: SessionOutboundMessage[] = [];
+ const session = createSessionForWorkspaceTests({
+ paseoHome,
+ worktreesRoot,
+ onMessage: (message) => {
+ if (isSessionOutboundMessage(message)) emitted.push(message);
+ },
+ });
+ // Real directory probe so the missing worktree reads as gone and the repo root as present.
+ session.filesystem.isDirectory = async (target: string) =>
+ existsSync(target) && statSync(target).isDirectory();
+
+ const projects = new Map>();
+ const workspaces = new Map>();
+ const workspaceId = "ws-real-recreate";
+ const agentId = "agent-real-recreate";
+ const projectId = repoDir;
+ projects.set(
+ projectId,
+ createPersistedProjectRecord({
+ projectId,
+ rootPath: repoDir,
+ kind: "git",
+ displayName: "worktree-project",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+ workspaces.set(
+ workspaceId,
+ createPersistedWorkspaceRecord({
+ workspaceId,
+ projectId,
+ cwd: worktreePath,
+ kind: "worktree",
+ branch,
+ displayName: branch,
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+
+ const storedAgent: StoredAgentRecord = {
+ ...makeStoredAgent({ id: agentId, cwd: worktreePath, updatedAt: "2026-03-10T00:00:00.000Z" }),
+ workspaceId,
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ };
+
+ session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
+ session.projectRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ projects.set(record.projectId, record);
+ };
+ session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
+ session.workspaceRegistry.upsert = async (
+ record: ReturnType,
+ ) => {
+ workspaces.set(record.workspaceId, record);
+ };
+ session.projectRegistry.list = async () => Array.from(projects.values());
+ session.workspaceRegistry.list = async () => Array.from(workspaces.values());
+ session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
+ session.agentStorage.upsert = async () => {};
+
+ const managed = makeManagedAgent({
+ id: agentId,
+ cwd: worktreePath,
+ workspaceId,
+ lifecycle: "idle",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ });
+ session.agentManager.getAgent = () => managed;
+ session.interruptAgentIfRunning = async () => undefined;
+ session.agentManager.reloadAgentSession = async () => managed;
+ session.agentManager.hydrateTimelineFromProvider = async () => undefined;
+ session.agentManager.getTimeline = () => [];
+ session.forwardAgentUpdate = async () => undefined;
+
+ await session.handleMessage({
+ type: "refresh_agent_request",
+ agentId,
+ requestId: "req-refresh-real-recreate",
+ });
+
+ expect(findByType(emitted, "rpc_error")).toBeUndefined();
+ expect(existsSync(worktreePath)).toBe(true);
+ const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
+ cwd: worktreePath,
+ stdio: "pipe",
+ })
+ .toString()
+ .trim();
+ expect(headBranch).toBe(branch);
+ expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
+ expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
+ expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
+
+ rmSync(tempDir, { recursive: true, force: true });
+});
+
+test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => {
+ const { tempDir, repoDir } = createRecreateWorktreeRepo();
+ const branch = "feature/keep";
+ execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
+
+ const worktreesRoot = path.join(tempDir, "worktrees");
+ const paseoHome = path.join(tempDir, "paseo-home");
+ const created = await createWorktree({
+ cwd: repoDir,
+ worktreeSlug: "keep",
+ source: { kind: "checkout-branch", branchName: branch },
+ runSetup: false,
+ paseoHome,
+ worktreesRoot,
+ });
+ const worktreePath = realpathSync(created.worktreePath);
+
+ const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
+ const projects = new Map>();
+ const workspaces = new Map>();
+ const workspaceId = "ws-missing-root";
+ const projectId = repoDir;
+ const missingRoot = path.join(tempDir, "does-not-exist");
+ projects.set(
+ projectId,
+ createPersistedProjectRecord({
+ projectId,
+ rootPath: missingRoot,
+ kind: "git",
+ displayName: "worktree-project",
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ }),
+ );
+ const workspaceRecord = createPersistedWorkspaceRecord({
+ workspaceId,
+ projectId,
+ cwd: worktreePath,
+ kind: "worktree",
+ branch,
+ displayName: branch,
+ createdAt: "2026-03-01T12:00:00.000Z",
+ updatedAt: "2026-03-10T00:00:00.000Z",
+ archivedAt: "2026-03-10T00:00:00.000Z",
+ });
+ workspaces.set(workspaceId, workspaceRecord);
+
+ session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
+ session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
+ session.filesystem.isDirectory = async (target: string) =>
+ existsSync(target) && statSync(target).isDirectory();
+
+ await expect(
+ session.recreateOwningWorktreeForRestore(workspaceRecord, branch),
+ ).rejects.toBeInstanceOf(WorktreeRequestError);
+ // Guard fires before createWorktree, so archivedAt is untouched.
+ expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
+
+ rmSync(tempDir, { recursive: true, force: true });
+});
+
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index be4bae891..3e5044157 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer {
checkoutRefresh: true,
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
workspaceMultiplicity: true,
+ // COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
+ worktreeRestore: true,
},
};
}