diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
index b2f4999a1..9763d7168 100644
--- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
+++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
-import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
+import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
return (
@@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
- navigateToPreparedWorkspaceTab({
+ navigateToAgent({
serverId,
- workspaceId: resolvedWorkspaceId,
- target: { kind: "agent", agentId },
+ agentId,
currentPathname: pathname,
});
}
@@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() {
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
- navigateToPreparedWorkspaceTab({
+ navigateToAgent({
serverId,
+ agentId,
workspaceId,
- target: { kind: "agent", agentId },
currentPathname: pathname,
});
return;
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 2c5daced0..fda62a4a3 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -328,6 +328,8 @@ export const ar: TranslationResources = {
workspace: {
route: {
loading: "جارٍ تحميل مساحة العمل",
+ restoring: "جارٍ استعادة مساحة العمل",
+ restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
connecting: "الاتصال",
hostOffline: "{{hostName}}غير متواجد حالياً",
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 0aa2c4b67..2e9cb0a09 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -327,6 +327,9 @@ export const en = {
workspace: {
route: {
loading: "Loading workspace",
+ restoring: "Restoring workspace",
+ restoreFailed:
+ "Couldn't restore this workspace — the directory may have been moved or deleted",
connecting: "Connecting",
hostOffline: "{{hostName}} is offline",
cannotReachHost: "Cannot reach {{hostName}}",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index a32cba7d1..603abd8d8 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -331,6 +331,9 @@ export const es: TranslationResources = {
workspace: {
route: {
loading: "Cargando espacio de trabajo",
+ restoring: "Restaurando espacio de trabajo",
+ restoreFailed:
+ "No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
connecting: "Conectando",
hostOffline: "{{hostName}}está desconectado",
cannotReachHost: "No se puede alcanzar{{hostName}}",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 3273037b2..c69b51d1e 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -331,6 +331,9 @@ export const fr: TranslationResources = {
workspace: {
route: {
loading: "Chargement de l'espace de travail",
+ restoring: "Restauration de l'espace de travail",
+ restoreFailed:
+ "Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
connecting: "De liaison",
hostOffline: "{{hostName}}est hors ligne",
cannotReachHost: "Impossible d'atteindre{{hostName}}",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 1af3ba116..40312d252 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -330,6 +330,9 @@ export const ru: TranslationResources = {
workspace: {
route: {
loading: "Загрузка рабочей области",
+ restoring: "Восстановление рабочей области",
+ restoreFailed:
+ "Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
connecting: "Подключение",
hostOffline: "{{hostName}}не в сети",
cannotReachHost: "Невозможно связаться с{{hostName}}",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 497e0d235..d79614a15 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -328,6 +328,8 @@ export const zhCN: TranslationResources = {
workspace: {
route: {
loading: "正在加载 workspace",
+ restoring: "正在恢复 workspace",
+ restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
connecting: "正在连接",
hostOffline: "{{hostName}} 已离线",
cannotReachHost: "无法连接 {{hostName}}",
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 f4c364563..ba40dad12 100644
--- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx
+++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx
@@ -21,6 +21,8 @@ export function renderWorkspaceRouteGate(input: {
switch (input.state.kind) {
case "loading":
return ;
+ case "restoring":
+ return ;
case "unreachable":
return (
);
@@ -70,6 +73,21 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
);
}
+function WorkspaceRestoring({ hostName }: { hostName: string }) {
+ const { theme } = useUnistyles();
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t("workspace.route.restoring")}
+ {hostName}
+
+
+ );
+}
+
function WorkspaceUnreachable({
state,
onRetry,
@@ -124,13 +142,23 @@ function WorkspaceUnreachable({
);
}
-function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
+function WorkspaceMissing({
+ hostName,
+ restoreFailed,
+ onDismiss,
+}: {
+ hostName: string;
+ restoreFailed: boolean;
+ onDismiss: () => void;
+}) {
const { t } = useTranslation();
return (
- {t("workspace.route.missing")}
+
+ {restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
+
{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 dc4ea674d..48d893959 100644
--- a/packages/app/src/screens/workspace/workspace-route-state.test.ts
+++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts
@@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: false,
+ restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: true,
+ restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
+ restoreStatus: null,
}),
).toEqual({
kind: "reconnecting",
@@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
+ restoreStatus: null,
}),
- ).toEqual({ kind: "missing", hostName: "Laptop" });
+ ).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
});
it("returns loading before workspace hydration when the host is online", () => {
@@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: false,
+ restoreStatus: null,
}),
).toEqual({ kind: "loading", hostName: "Laptop" });
});
@@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
+ restoreStatus: null,
+ }),
+ ).toEqual({ kind: "ready" });
+ });
+
+ it("returns restoring while an archived workspace restore is in flight", () => {
+ expect(
+ resolveWorkspaceRouteState({
+ hostName: "Laptop",
+ connectionStatus: "online",
+ lastError: null,
+ workspace: null,
+ hasHydratedWorkspaces: true,
+ restoreStatus: "restoring",
+ }),
+ ).toEqual({ kind: "restoring", hostName: "Laptop" });
+ });
+
+ it("falls back to a restore-failed missing state once the restore times out", () => {
+ expect(
+ resolveWorkspaceRouteState({
+ hostName: "Laptop",
+ connectionStatus: "online",
+ lastError: null,
+ workspace: null,
+ hasHydratedWorkspaces: true,
+ restoreStatus: "failed",
+ }),
+ ).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
+ });
+
+ it("resolves to ready when the descriptor arrives even while restoring", () => {
+ expect(
+ resolveWorkspaceRouteState({
+ hostName: "Laptop",
+ connectionStatus: "online",
+ lastError: null,
+ workspace: createWorkspaceDescriptor(),
+ hasHydratedWorkspaces: true,
+ restoreStatus: "restoring",
}),
).toEqual({ kind: "ready" });
});
diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts
index 0c4e2ef89..0f689b2bd 100644
--- a/packages/app/src/screens/workspace/workspace-route-state.ts
+++ b/packages/app/src/screens/workspace/workspace-route-state.ts
@@ -16,7 +16,8 @@ export type WorkspaceRouteState =
lastError: string | null;
}
| { kind: "loading"; hostName: string }
- | { kind: "missing"; hostName: string };
+ | { kind: "restoring"; hostName: string }
+ | { kind: "missing"; hostName: string; restoreFailed: boolean };
export function resolveWorkspaceRouteState(input: {
hostName: string;
@@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: {
lastError: string | null;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
+ restoreStatus: "restoring" | "failed" | null;
}): WorkspaceRouteState {
if (input.workspace) {
if (input.connectionStatus === "online") {
@@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: {
}
if (input.connectionStatus === "online") {
+ if (input.restoreStatus === "restoring") {
+ return { kind: "restoring", hostName: input.hostName };
+ }
+
if (input.hasHydratedWorkspaces) {
- return { kind: "missing", hostName: input.hostName };
+ return {
+ kind: "missing",
+ hostName: input.hostName,
+ restoreFailed: input.restoreStatus === "failed",
+ };
}
return { kind: "loading", hostName: input.hostName };
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 2de57ad9c..50896cf98 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -71,7 +71,11 @@ import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
-import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
+import {
+ useSessionStore,
+ useWorkspaceRestoreStatus,
+ type WorkspaceDescriptor,
+} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
@@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
function useResolvedWorkspaceRouteState(input: {
serverId: string;
+ workspaceId: string;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
}): WorkspaceRouteState {
@@ -1497,6 +1502,7 @@ function useResolvedWorkspaceRouteState(input: {
);
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
+ const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
return useMemo(
() =>
@@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: {
lastError: hostSnapshot?.lastError ?? null,
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
+ restoreStatus,
}),
[
hostName,
@@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: {
hostSnapshot?.lastError,
input.workspace,
input.hasHydratedWorkspaces,
+ restoreStatus,
],
);
}
@@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({
);
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts
index 650f92168..7e7f78d32 100644
--- a/packages/app/src/stores/session-store.test.ts
+++ b/packages/app/src/stores/session-store.test.ts
@@ -363,6 +363,51 @@ describe("mergeWorkspaces", () => {
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
});
+
+ it("clears a pending restore status when the matching descriptor lands", () => {
+ const store = useSessionStore.getState();
+ initializeTestSession();
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
+ expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
+ "restoring",
+ );
+
+ store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
+
+ expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
+ });
+});
+
+describe("setWorkspaceRestoreStatus", () => {
+ it("marks restoring then failed while the workspace is still absent", () => {
+ const store = useSessionStore.getState();
+ initializeTestSession();
+
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
+
+ expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
+ });
+
+ it("ignores a late failed once the descriptor has landed (no-op)", () => {
+ const store = useSessionStore.getState();
+ initializeTestSession();
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
+ store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
+
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
+
+ expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
+ });
+
+ it("ignores failed when no restore is in flight", () => {
+ const store = useSessionStore.getState();
+ initializeTestSession();
+
+ store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
+
+ expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
+ });
});
describe("setWorkspaces", () => {
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index e265d5643..a33b80792 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -287,6 +287,8 @@ export interface AgentTimelineCursorState {
endSeq: number;
}
+export type WorkspaceRestoreStatus = "restoring" | "failed";
+
// Per-session state
export interface SessionState {
serverId: string;
@@ -332,6 +334,9 @@ export interface SessionState {
// Project parents with no active workspaces, keyed by projectId. Rendered as
// empty project rows in the sidebar.
emptyProjects: Map;
+ // Transient restore state for archived workspaces, keyed by normalized
+ // workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
+ restoringWorkspaces: Map;
// Permissions
pendingPermissions: Map;
@@ -455,6 +460,12 @@ interface SessionStoreActions {
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
+ setWorkspaceRestoreStatus: (
+ serverId: string,
+ workspaceId: string,
+ status: WorkspaceRestoreStatus,
+ ) => void;
+ clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
// Agent activity timestamps
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
@@ -527,6 +538,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentDetails: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
+ restoringWorkspaces: new Map(),
pendingPermissions: new Map(),
fileExplorer: new Map(),
queuedMessages: new Map(),
@@ -1253,6 +1265,54 @@ export const useSessionStore = create()(
});
},
+ setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
+ set((prev) => {
+ const session = prev.sessions[serverId];
+ if (!session) {
+ return prev;
+ }
+ if (session.restoringWorkspaces.get(workspaceId) === status) {
+ return prev;
+ }
+ // A late dir-gone timeout must not override a successful restore:
+ // only mark failed while still restoring and the descriptor is absent.
+ if (
+ status === "failed" &&
+ (session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
+ session.workspaces.has(workspaceId))
+ ) {
+ return prev;
+ }
+ const next = new Map(session.restoringWorkspaces);
+ next.set(workspaceId, status);
+ return {
+ ...prev,
+ sessions: {
+ ...prev.sessions,
+ [serverId]: { ...session, restoringWorkspaces: next },
+ },
+ };
+ });
+ },
+
+ clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
+ set((prev) => {
+ const session = prev.sessions[serverId];
+ if (!session || !session.restoringWorkspaces.has(workspaceId)) {
+ return prev;
+ }
+ const next = new Map(session.restoringWorkspaces);
+ next.delete(workspaceId);
+ return {
+ ...prev,
+ sessions: {
+ ...prev.sessions,
+ [serverId]: { ...session, restoringWorkspaces: next },
+ },
+ };
+ });
+ },
+
mergeWorkspaces: (serverId, workspaces) => {
const nextEntries = Array.from(workspaces);
set((prev) => {
@@ -1266,10 +1326,18 @@ export const useSessionStore = create()(
// empty: prune any stale empty descriptor so it stops governing the
// project's rendered metadata.
const nextEmptyProjects = new Map(session.emptyProjects);
+ // A descriptor arriving is the success signal for a pending restore:
+ // clear it at the source so every entry point converges to "ready".
+ let nextRestoring: Map | null = null;
for (const workspace of nextEntries) {
if (nextEmptyProjects.delete(workspace.projectId)) {
changed = true;
}
+ if (session.restoringWorkspaces.has(workspace.id)) {
+ nextRestoring ??= new Map(session.restoringWorkspaces);
+ nextRestoring.delete(workspace.id);
+ changed = true;
+ }
const existing = next.get(workspace.id);
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
if (existing === nextWorkspace) {
@@ -1285,7 +1353,12 @@ export const useSessionStore = create()(
...prev,
sessions: {
...prev.sessions,
- [serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
+ [serverId]: {
+ ...session,
+ workspaces: next,
+ emptyProjects: nextEmptyProjects,
+ restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
+ },
},
};
});
@@ -1485,3 +1558,14 @@ export const useSessionStore = create()(
};
}),
);
+
+export function useWorkspaceRestoreStatus(
+ serverId: string | null,
+ workspaceId: string | null,
+): WorkspaceRestoreStatus | null {
+ return useSessionStore((state) =>
+ serverId && workspaceId
+ ? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
+ : null,
+ );
+}
diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts
index f674dbf3e..e2a0b9233 100644
--- a/packages/app/src/utils/navigate-to-agent/index.ts
+++ b/packages/app/src/utils/navigate-to-agent/index.ts
@@ -1,10 +1,51 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
+import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
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;
+
+function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
+ const snapshot = getHostRuntimeStore().getSnapshot(serverId);
+ const client = snapshot?.client ?? null;
+ if (!client || !isHostRuntimeConnected(snapshot)) {
+ return;
+ }
+
+ const store = useSessionStore.getState();
+ const session = store.sessions[serverId];
+ // Self-gate: only an archived agent whose workspace is absent needs restoring.
+ // A still-present workspace or an in-flight restore is a no-op; fire-once is
+ // derived from store state.
+ const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
+ if (!agent?.archivedAt) {
+ return;
+ }
+ if (session?.workspaces.has(workspaceId)) {
+ return;
+ }
+ if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
+ return;
+ }
+
+ store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
+ // The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
+ setTimeout(
+ () => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
+ RESTORE_TIMEOUT_MS,
+ );
+ client
+ .refreshAgent(agentId)
+ .catch(() =>
+ useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
+ );
+}
+
export function navigateToAgent(input: NavigateToAgentInput): string {
return resolveNavigateToAgent(input, {
readAgentNavTarget: ({ serverId, agentId }) => {
@@ -18,5 +59,8 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
router.navigate(route as Href);
},
navigateToPreparedWorkspaceTab,
+ restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
+ restoreArchivedWorkspace(serverId, agentId, workspaceId);
+ },
});
}
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
index b3dd22123..e061ea050 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
@@ -16,16 +16,25 @@ interface RecordedHostNav {
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
+interface RecordedRestore {
+ serverId: string;
+ agentId: string;
+ workspaceId: string;
+}
+
function createFakeNavigators(target: AgentNavTarget): {
deps: NavigateToAgentDeps;
hostNavigations: RecordedHostNav[];
tabNavigations: RecordedTabNav[];
+ restores: RecordedRestore[];
} {
const hostNavigations: RecordedHostNav[] = [];
const tabNavigations: RecordedTabNav[] = [];
+ const restores: RecordedRestore[] = [];
return {
hostNavigations,
tabNavigations,
+ restores,
deps: {
readAgentNavTarget: () => target,
navigateToHostAgent: (route) => {
@@ -35,6 +44,9 @@ function createFakeNavigators(target: AgentNavTarget): {
tabNavigations.push(input);
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
},
+ restoreArchivedWorkspace: (input) => {
+ restores.push(input);
+ },
},
};
}
@@ -63,6 +75,55 @@ describe("resolveNavigateToAgent", () => {
]);
});
+ it("delegates a restore attempt whenever a workspaceId resolves", () => {
+ const { deps, restores, tabNavigations } = createFakeNavigators({
+ agentWorkspaceId: WORKSPACE_ID,
+ });
+
+ resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
+
+ expect(restores).toEqual([
+ { serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
+ ]);
+ expect(tabNavigations).toHaveLength(1);
+ });
+
+ it("uses the input workspaceId without reading the nav target", () => {
+ const readTargets: { serverId: string; agentId: string }[] = [];
+ const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
+ deps.readAgentNavTarget = (input) => {
+ readTargets.push(input);
+ return { agentWorkspaceId: null };
+ };
+
+ resolveNavigateToAgent(
+ { serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
+ deps,
+ );
+
+ expect(readTargets).toEqual([]);
+ expect(tabNavigations).toEqual([
+ {
+ serverId: SERVER_ID,
+ workspaceId: WORKSPACE_ID,
+ target: { kind: "agent", agentId: AGENT_ID },
+ currentPathname: undefined,
+ pin: undefined,
+ },
+ ]);
+ });
+
+ it("does not trigger a restore when no workspaceId resolves", () => {
+ const { deps, restores, hostNavigations } = createFakeNavigators({
+ agentWorkspaceId: null,
+ });
+
+ resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
+
+ expect(restores).toEqual([]);
+ expect(hostNavigations).toHaveLength(1);
+ });
+
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null,
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts
index a472a9d46..61188d4b0 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.ts
@@ -5,6 +5,9 @@ import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-worksp
export interface NavigateToAgentInput {
serverId: string;
agentId: string;
+ // Used as the workspace target when the agent is not yet in the session store
+ // (cold deep-links). Otherwise the workspace is read from the store.
+ workspaceId?: string | null;
currentPathname?: string | null;
pin?: boolean;
}
@@ -17,16 +20,20 @@ export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
navigateToHostAgent: (route: string) => void;
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
+ restoreArchivedWorkspace: (input: {
+ serverId: string;
+ agentId: string;
+ workspaceId: string;
+ }) => void;
}
export function resolveNavigateToAgent(
input: NavigateToAgentInput,
deps: NavigateToAgentDeps,
): string {
- const { agentWorkspaceId } = deps.readAgentNavTarget({
- serverId: input.serverId,
- agentId: input.agentId,
- });
+ const agentWorkspaceId =
+ input.workspaceId ??
+ deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
@@ -35,6 +42,14 @@ export function resolveNavigateToAgent(
return route;
}
+ // Restore self-gates on the agent being archived with its workspace absent, so
+ // ordinary navigations are a cheap no-op.
+ deps.restoreArchivedWorkspace({
+ serverId: input.serverId,
+ agentId: input.agentId,
+ workspaceId,
+ });
+
return deps.navigateToPreparedWorkspaceTab({
serverId: input.serverId,
workspaceId,
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
new file mode 100644
index 000000000..098327165
--- /dev/null
+++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts
@@ -0,0 +1,164 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
+
+const refreshAgent = vi.fn<(agentId: string) => Promise>();
+let connected = true;
+
+vi.mock("expo-router", () => ({
+ router: { navigate: vi.fn() },
+}));
+
+vi.mock("@/utils/workspace-navigation", () => ({
+ navigateToPreparedWorkspaceTab: vi.fn(() => ""),
+}));
+
+vi.mock("@/runtime/host-runtime", () => ({
+ getHostRuntimeStore: () => ({
+ getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
+ }),
+ isHostRuntimeConnected: () => connected,
+}));
+
+import { useSessionStore, type Agent } from "@/stores/session-store";
+import { navigateToAgent } from "./index";
+
+const SERVER_ID = "server-1";
+const AGENT_ID = "agent-1";
+const WORKSPACE_ID = "workspace-1";
+
+function status(): "restoring" | "failed" | null {
+ return (
+ useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
+ );
+}
+
+function seedArchivedAgent(): void {
+ const store = useSessionStore.getState();
+ store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
+ store.setAgents(SERVER_ID, (prev) => {
+ const next = new Map(prev);
+ next.set(AGENT_ID, {
+ id: AGENT_ID,
+ workspaceId: WORKSPACE_ID,
+ archivedAt: new Date(),
+ } as unknown as Agent);
+ return next;
+ });
+}
+
+describe("restoreArchivedWorkspace via navigateToAgent", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ refreshAgent.mockReset();
+ connected = true;
+ seedArchivedAgent();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ useSessionStore.getState().clearSession(SERVER_ID);
+ });
+
+ function trigger(): void {
+ navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
+ }
+
+ it("calls refreshAgent once and marks the workspace restoring", () => {
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+
+ trigger();
+
+ expect(refreshAgent).toHaveBeenCalledTimes(1);
+ expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
+ expect(status()).toBe("restoring");
+ });
+
+ it("does not re-fire while a restore for the same workspace is in flight", () => {
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+
+ trigger();
+ trigger();
+ trigger();
+
+ expect(refreshAgent).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fire for a non-archived agent", () => {
+ const store = useSessionStore.getState();
+ store.setAgents(SERVER_ID, (prev) => {
+ const next = new Map(prev);
+ next.set(AGENT_ID, {
+ id: AGENT_ID,
+ workspaceId: WORKSPACE_ID,
+ archivedAt: undefined,
+ } as unknown as Agent);
+ return next;
+ });
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+
+ trigger();
+
+ expect(refreshAgent).not.toHaveBeenCalled();
+ expect(status()).toBeNull();
+ });
+
+ it("does not fire while disconnected", () => {
+ connected = false;
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+
+ trigger();
+
+ expect(refreshAgent).not.toHaveBeenCalled();
+ expect(status()).toBeNull();
+ });
+
+ it("flips to failed when refreshAgent rejects", async () => {
+ refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
+
+ trigger();
+ expect(status()).toBe("restoring");
+
+ await vi.runAllTicks();
+ await Promise.resolve();
+
+ expect(status()).toBe("failed");
+ });
+
+ it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
+ refreshAgent.mockImplementation(() => Promise.resolve({}));
+
+ trigger();
+ await Promise.resolve();
+ expect(status()).toBe("restoring");
+
+ await vi.advanceTimersByTimeAsync(7000);
+ expect(status()).toBe("failed");
+ });
+
+ it("is a no-op when the workspace descriptor is already present", () => {
+ refreshAgent.mockImplementation(() => new Promise(() => {}));
+ useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
+ {
+ id: WORKSPACE_ID,
+ projectId: "project-1",
+ projectDisplayName: "Project 1",
+ projectRootPath: "/repo",
+ workspaceDirectory: "/repo",
+ projectKind: "git",
+ workspaceKind: "local_checkout",
+ name: "main",
+ status: "done",
+ statusEnteredAt: null,
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ },
+ ]);
+
+ trigger();
+
+ expect(refreshAgent).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts
index 1da1e5df1..e1ffbbd98 100644
--- a/packages/server/src/server/paseo-worktree-service.test.ts
+++ b/packages/server/src/server/paseo-worktree-service.test.ts
@@ -65,6 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
+ expect(result.workspace.baseBranch).toBe("main");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
@@ -425,6 +426,9 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
version: 1,
baseRefName: "dev",
});
+ // A checkout-branch worktree has no distinct base, so the workspace records a
+ // null baseBranch even though worktree.json's baseRefName is the branch itself.
+ expect(created.workspace.baseBranch).toBe(null);
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts
index 9be866f05..da2e9e373 100644
--- a/packages/server/src/server/paseo-worktree-service.ts
+++ b/packages/server/src/server/paseo-worktree-service.ts
@@ -21,6 +21,7 @@ import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
+ normalizeBaseRefName,
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js";
@@ -71,6 +72,7 @@ export async function createPaseoWorktree(
projectId: input.projectId,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
+ baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
deps,
});
@@ -197,11 +199,25 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
});
}
+// The base branch is normalized to match worktree.json's baseRefName (origin/
+// stripped). checkout-branch worktrees have no distinct base, so they stay null.
+function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null {
+ switch (intent.kind) {
+ case "branch-off":
+ return normalizeBaseRefName(intent.baseBranch);
+ case "checkout-github-pr":
+ return normalizeBaseRefName(intent.baseRefName);
+ case "checkout-branch":
+ return null;
+ }
+}
+
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
+ baseBranch?: string | null;
title?: string | null;
deps: Pick<
CreatePaseoWorktreeDeps,
@@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: {
kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd,
branch: options.worktree.branchName || null,
+ baseBranch: options.baseBranch ?? null,
title: options.title ?? null,
createdAt: now,
updatedAt: now,
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index b0548fd81..827922dc7 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -3503,6 +3503,7 @@ export class Session {
try {
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
+ await this.unarchiveOwningWorkspaceForAgent(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
@@ -7066,6 +7067,23 @@ export class Session {
};
}
+ private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise {
+ const record = await this.agentStorage.get(agentId);
+ 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;
+ }
+ await this.ensureWorkspaceRecordUnarchived(workspace);
+ await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
+ }
+
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 33467e501..d93515d10 100644
--- a/packages/server/src/server/session.workspaces.test.ts
+++ b/packages/server/src/server/session.workspaces.test.ts
@@ -83,6 +83,8 @@ interface SessionTestAccess {
};
agentManager: {
listAgents(): unknown[];
+ getAgent(agentId: string): unknown;
+ reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise;
listImportableSessions(options?: unknown): Promise;
importProviderSession(input: unknown): Promise;
resumeAgentFromPersistence(
@@ -147,6 +149,9 @@ interface SessionTestAccess {
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
};
+ filesystem: {
+ isDirectory(cwd: string): Promise;
+ };
}
interface ListFetchResult {
@@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project
expect(response?.payload.workspace?.id).toBe(workspaceId);
});
+test("refresh_agent_request unarchives the owning workspace when its directory exists", 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-unit2-existing-dir");
+ session.filesystem.isDirectory = async () => true;
+ const workspaceId = "ws-repo-archived";
+ const agentId = "agent-archived";
+ projects.set(
+ cwd,
+ createPersistedProjectRecord({
+ projectId: cwd,
+ rootPath: cwd,
+ kind: "non_git",
+ displayName: "repo",
+ 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: "directory",
+ displayName: "repo",
+ 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 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-unarchive",
+ });
+
+ expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
+ expect(projects.get(cwd)?.archivedAt).toBeNull();
+ expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
+ expect(findByType(emitted, "rpc_error")).toBeUndefined();
+});
+
+test("refresh_agent_request leaves the owning workspace archived when its directory is missing", 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-missing-workspace-dir");
+ session.filesystem.isDirectory = async () => false;
+ const workspaceId = "ws-missing-dir";
+ const agentId = "agent-missing-dir";
+ projects.set(
+ cwd,
+ createPersistedProjectRecord({
+ projectId: cwd,
+ rootPath: cwd,
+ kind: "non_git",
+ displayName: "missing",
+ 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: "directory",
+ displayName: "missing",
+ 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 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-missing-dir",
+ });
+
+ expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
+ expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
+});
+
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/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts
index b8249d50c..b2d79801d 100644
--- a/packages/server/src/server/workspace-registry.ts
+++ b/packages/server/src/server/workspace-registry.ts
@@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({
.nullable()
.optional()
.transform((value) => value ?? null),
+ // The base branch the worktree was created from (normalized like worktree.json's
+ // baseRefName). Only worktree workspaces carry a base branch; checkout-branch
+ // worktrees and directory/local_checkout workspaces leave it null.
+ baseBranch: z
+ .string()
+ .nullable()
+ .optional()
+ .transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
@@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: {
displayName: string;
title?: string | null;
branch?: string | null;
+ baseBranch?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: {
...input,
title: input.title ?? null,
branch: input.branch ?? null,
+ baseBranch: input.baseBranch ?? null,
archivedAt: input.archivedAt ?? null,
});
}