diff --git a/.changeset/legacy-federated-session-actions.md b/.changeset/legacy-federated-session-actions.md
new file mode 100644
index 0000000..45098d9
--- /dev/null
+++ b/.changeset/legacy-federated-session-actions.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities.
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index cdc4f6e..2626f9d 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -20,6 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
+import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
@@ -1016,7 +1017,10 @@ export class PiWebApp extends LitElement {
private canDeleteArchivedSessions(): boolean {
const runtime = this.selectedMachineRuntime();
- return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
+ // COMPAT-CAP sessions.deleteArchived: older federated machines may support
+ // the legacy DELETE route without advertising runtime capabilities. Only
+ // block when capability discovery succeeds and reports no support.
+ return runtime?.ok !== true || supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
}
private canReloadSessions(): boolean {
@@ -1029,6 +1033,10 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
}
+ private hasAuthoritativeSessionPersistence(): boolean {
+ return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime());
+ }
+
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
@@ -1124,6 +1132,7 @@ export class PiWebApp extends LitElement {
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.canReloadSessions=${this.canReloadSessions()}
.canCleanupSessions=${this.canCleanupSessions()}
+ .authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
.collapsible=${true}
diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts
index a1009fc..648a595 100644
--- a/src/client/src/components/SessionList.test.ts
+++ b/src/client/src/components/SessionList.test.ts
@@ -27,11 +27,17 @@ describe("sessionRowActivityKind", () => {
});
describe("session action eligibility", () => {
- it("requires a persisted server signal before archiving", () => {
- expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true);
- expect(isArchivableSessionInfo(session("unknown"))).toBe(false);
- expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false);
- expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
+ it("requires a persisted server signal before archiving when persistence is authoritative", () => {
+ const authoritative = { authoritative: true };
+ expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true);
+ expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).toBe(false);
+ expect(isArchivableSessionInfo(session("transient", { persisted: false }), undefined, authoritative)).toBe(false);
+ expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }, undefined, authoritative)).toBe(false);
+ });
+
+ it("preserves legacy archiving when persistence support is not advertised", () => {
+ expect(isArchivableSessionInfo(session("legacy"))).toBe(true);
+ expect(isTransientNewSessionInfo(session("legacy"))).toBe(false);
});
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts
index 67cbc55..ccde342 100644
--- a/src/client/src/components/SessionList.ts
+++ b/src/client/src/components/SessionList.ts
@@ -36,6 +36,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ type: Boolean }) canDeleteArchived = false;
@property({ type: Boolean }) canReload = false;
@property({ type: Boolean }) canCleanup = false;
+ @property({ type: Boolean }) authoritativeSessionPersistence = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false;
@@ -193,7 +194,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
const selectedSessions = this.selectedSessions("current");
- const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
+ const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html`
@@ -234,8 +235,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id];
const activity = this.activities[session.id];
- const canArchive = isArchivableSessionInfo(session, status);
- const canDeleteTransient = isTransientNewSessionInfo(session, status);
+ const persistenceOptions = this.sessionPersistenceOptions();
+ const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
+ const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
const canReloadSession = canArchive && this.canReload;
return html`
isArchivableSessionInfo(session, this.statuses[session.id]));
+ const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
void this.onArchiveMany?.(sessions);
}
@@ -395,7 +397,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
- if (isTransientNewSessionInfo(session, status)) {
+ if (isTransientNewSessionInfo(session, status, this.sessionPersistenceOptions())) {
if (activity?.phase === "active") return "creating · ";
if (activity?.phase === "error") return "error · ";
return "new · ";
@@ -404,6 +406,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return "";
}
+ private sessionPersistenceOptions() {
+ return { authoritative: this.authoritativeSessionPersistence };
+ }
+
private renderActivity(session: SessionInfo) {
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts
index 5a2fc40..9029fa7 100644
--- a/src/client/src/components/appShell/AppNavigationPanel.ts
+++ b/src/client/src/components/appShell/AppNavigationPanel.ts
@@ -44,6 +44,7 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: Boolean }) canReloadSessions = false;
@property({ type: Boolean }) canCleanupSessions = false;
+ @property({ type: Boolean }) authoritativeSessionPersistence = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) onShowActions?: () => void;
@@ -165,6 +166,7 @@ export class AppNavigationPanel extends LitElement {
.canDeleteArchived=${this.canDeleteArchivedSessions}
.canReload=${this.canReloadSessions}
.canCleanup=${this.canCleanupSessions}
+ .authoritativeSessionPersistence=${this.authoritativeSessionPersistence}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
.collapsible=${this.collapsible}
diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts
index 385de44..ba2977a 100644
--- a/src/client/src/controllers/sessionController.test.ts
+++ b/src/client/src/controllers/sessionController.test.ts
@@ -1124,6 +1124,31 @@ describe("SessionController", () => {
expect(urlUpdates).toEqual([undefined]);
});
+ it("archives legacy sessions when persistence support is not advertised", async () => {
+ const legacySession = { ...oldSession };
+ const archivedIds: string[] = [];
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] };
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ archive: (session) => {
+ archivedIds.push(sessionLookupId(session));
+ return Promise.resolve({ archived: true });
+ },
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ new InMemorySessionSelectionMemory(),
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.archiveSession(legacySession);
+
+ expect(archivedIds).toEqual([legacySession.id]);
+ expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true });
+ });
+
it("archives selected session descendants and selects the next active session", async () => {
const persistedSession = { ...oldSession, persisted: true };
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
@@ -1388,10 +1413,10 @@ describe("SessionController", () => {
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
});
- it("does not delete archived sessions when the selected machine runtime does not support it", async () => {
+ it("does not delete archived sessions when the selected machine runtime reports no support", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = [];
- let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
@@ -1414,6 +1439,32 @@ describe("SessionController", () => {
expect(state.error).toContain("requires an updated Pi-Web runtime");
});
+ it("allows legacy archived-session deletion when runtime support is unknown", async () => {
+ const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
+ const deletedIds: string[] = [];
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] };
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ deleteArchived: (session) => {
+ deletedIds.push(sessionLookupId(session));
+ return Promise.resolve({ deleted: true });
+ },
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ new InMemorySessionSelectionMemory(),
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.deleteArchivedSessions([archivedSession]);
+
+ expect(deletedIds).toEqual([archivedSession.id]);
+ expect(state.sessions).toEqual([]);
+ expect(state.error).toBe("");
+ });
+
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
const persistedSession = { ...oldSession, persisted: true };
const cacheKey = sessionKey(oldSession.id);
@@ -1496,14 +1547,14 @@ describe("SessionController", () => {
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
});
- it("does not reload sessions from disk without a persisted server signal", async () => {
+ it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => {
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
- machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
+ machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } },
};
const api: typeof defaultApi = {
...defaultApi,
diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts
index 619687a..d9ec288 100644
--- a/src/client/src/controllers/sessionController.ts
+++ b/src/client/src/controllers/sessionController.ts
@@ -8,7 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
import { fileCompletionInsertText } from "../promptCompletions";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
-import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
+import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
@@ -389,11 +389,12 @@ export class SessionController {
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
const status = this.statusForSession(session);
- if (isTransientNewSessionInfo(session, status)) {
+ const persistenceOptions = this.sessionPersistenceOptions();
+ if (isTransientNewSessionInfo(session, status, persistenceOptions)) {
await this.deleteCachedNewSession(session);
return;
}
- if (!isArchivableSessionInfo(session, status)) return;
+ if (!isArchivableSessionInfo(session, status, persistenceOptions)) return;
try {
await this.api.archive(session, selectedMachineId(this.getState()));
const state = this.getState();
@@ -409,7 +410,7 @@ export class SessionController {
}
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
- if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
+ if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
try {
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
@@ -426,7 +427,8 @@ export class SessionController {
}
async archiveSessions(sessions: readonly SessionInfo[]): Promise {
- const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session)));
+ const persistenceOptions = this.sessionPersistenceOptions();
+ const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session), persistenceOptions));
if (candidates.length === 0) return;
try {
@@ -453,7 +455,9 @@ export class SessionController {
const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId];
- if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
+ // Preserve legacy federated deletes when capability discovery is unavailable;
+ // only a positive runtime response without support should block the action.
+ if (runtime?.ok === true && !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
return;
}
@@ -559,7 +563,7 @@ export class SessionController {
}
async deleteCachedNewSession(session = this.getState().selectedSession) {
- if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return;
+ if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined;
if (pendingStart !== undefined) {
pendingStart.discarded = true;
@@ -605,7 +609,7 @@ export class SessionController {
}
async reloadSession(session = this.getState().selectedSession) {
- if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
+ if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session), this.sessionPersistenceOptions())) return;
const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
@@ -757,6 +761,11 @@ export class SessionController {
return state.sessionStatuses[session.id];
}
+ private sessionPersistenceOptions() {
+ const state = this.getState();
+ return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]);
+ }
+
private workspaceSelectionKey(cwd: string): string {
return `${selectedMachineId(this.getState())}:${cwd}`;
}
diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts
index 622fc42..322e1e9 100644
--- a/src/client/src/plugins/core/actions.ts
+++ b/src/client/src/plugins/core/actions.ts
@@ -2,7 +2,7 @@ import { isSessionActive } from "../../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
import type { AppState } from "../../appState";
import { selectedMachineId } from "../../controllers/types";
-import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence";
+import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../../sessionPersistence";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types";
@@ -217,25 +217,29 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
}
function hasArchivableSession(context: { state: AppState }): boolean {
- return isArchivableSessionInfo(context.state.selectedSession, context.state.status);
+ return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
}
function hasTransientNewSession(context: { state: AppState }): boolean {
- return isTransientNewSessionInfo(context.state.selectedSession, context.state.status);
+ return isTransientNewSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
}
function hasReloadableSession(context: { state: AppState }): boolean {
- if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false;
+ if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return false;
if (reloadSessionDisabledReason(context) !== undefined) return false;
return !isSessionActive(context.state.status, context.state.activity);
}
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
- if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined;
+ if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
}
+function sessionPersistenceOptions(state: AppState) {
+ return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]);
+}
+
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
const runtime = state.machineRuntimes[selectedMachineId(state)];
if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined;
diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts
index ce47538..bdb1de3 100644
--- a/src/client/src/plugins/registry.test.ts
+++ b/src/client/src/plugins/registry.test.ts
@@ -177,14 +177,19 @@ describe("PluginRegistry", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
- const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
+ const persistedStateRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] } };
+ const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: persistedStateRuntime }).context);
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
- const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
+ const unknownActions = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: persistedStateRuntime }).context);
expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
+ const legacyUnknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
+ expect(legacyUnknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
+ expect(legacyUnknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
+
const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context);
expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
@@ -214,7 +219,8 @@ describe("PluginRegistry", () => {
it("enables session disk reload only for a writable session on a capable, idle runtime", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
- const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
+ const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
+ const legacyReloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context);
const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
@@ -230,6 +236,9 @@ describe("PluginRegistry", () => {
const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
+ const legacyUnknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: legacyReloadRuntime }).context);
+ expect(legacyUnknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
+
const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context);
expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
diff --git a/src/client/src/sessionPersistence.ts b/src/client/src/sessionPersistence.ts
index 32db583..8cc8e3e 100644
--- a/src/client/src/sessionPersistence.ts
+++ b/src/client/src/sessionPersistence.ts
@@ -1,21 +1,40 @@
-import type { SessionInfo, SessionStatus } from "./api";
+import type { MachineRuntime, SessionInfo, SessionStatus } from "./api";
import { isCachedNewSessionInfo } from "./cachedNewSessions";
+import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../shared/capabilities";
export type SessionPersistenceState = "persisted" | "transient" | "unknown";
-export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus): SessionPersistenceState {
+export interface SessionPersistenceOptions {
+ /**
+ * True when the selected runtime advertises reliable persisted/transient
+ * session state. Legacy federated runtimes omit this field, so missing data
+ * must preserve the old "listed sessions are persisted" behavior.
+ */
+ authoritative?: boolean;
+}
+
+export function hasAuthoritativeSessionPersistence(runtime: Pick | undefined): boolean {
+ return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsPersistedState);
+}
+
+export function sessionPersistenceOptionsForRuntime(runtime: Pick | undefined): SessionPersistenceOptions {
+ return { authoritative: hasAuthoritativeSessionPersistence(runtime) };
+}
+
+export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus, options: SessionPersistenceOptions = {}): SessionPersistenceState {
if (session === undefined) return "unknown";
const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined;
const persisted = statusPersisted ?? session.persisted;
if (persisted === true) return "persisted";
if (persisted === false || isCachedNewSessionInfo(session)) return "transient";
+ if (options.authoritative !== true) return "persisted";
return "unknown";
}
-export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
- return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted";
+export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean {
+ return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "persisted";
}
-export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
- return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient";
+export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus, options?: SessionPersistenceOptions): boolean {
+ return session !== undefined && session.archived !== true && sessionPersistenceState(session, status, options) === "transient";
}
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 78e86b5..ee25823 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsBulkMutations: "sessions.bulkMutations",
sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload",
+ sessionsPersistedState: "sessions.persistedState",
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts
index 2e75074..aa03c5c 100644
--- a/src/shared/capabilities.test.ts
+++ b/src/shared/capabilities.test.ts
@@ -14,6 +14,20 @@ describe("PI WEB capabilities", () => {
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
});
+ it("requires web and session daemon support for authoritative session persistence", () => {
+ expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
+ expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
+
+ expect(effectivePiWebCapabilities({
+ web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
+ sessiond: { available: false, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
+ })).not.toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
+ expect(effectivePiWebCapabilities({
+ web: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
+ sessiond: { available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsPersistedState] },
+ })).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
+ });
+
it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts
index b009081..8d00989 100644
--- a/src/shared/capabilities.ts
+++ b/src/shared/capabilities.ts
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
+ PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
@@ -22,6 +23,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
+ PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[];
@@ -30,6 +32,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
+ [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],