diff --git a/.changeset/session-reload-from-disk.md b/.changeset/session-reload-from-disk.md
new file mode 100644
index 0000000..7f86181
--- /dev/null
+++ b/.changeset/session-reload-from-disk.md
@@ -0,0 +1,9 @@
+---
+"@jmfederico/pi-web": minor
+---
+
+Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history.
+
+Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it.
+
+Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect.
diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts
index 389f61e..628cdd5 100644
--- a/src/client/src/api/clients.ts
+++ b/src/client/src/api/clients.ts
@@ -10,7 +10,6 @@ import {
parseCommandResult,
parseDeleted,
parseDetached,
- parseReloaded,
parseFileContentResponse,
parseFileSuggestion,
parseFileTreeResponse,
@@ -28,6 +27,7 @@ import {
parsePiWebRuntimeResponse,
parsePiWebStatusResponse,
parseProject,
+ parseReloaded,
parseRestored,
parseSavedAttachments,
parseSessionInfo,
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 2cb62a8..db9132e 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -999,6 +999,11 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
}
+ private canReloadSessions(): boolean {
+ const runtime = this.selectedMachineRuntime();
+ return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
+ }
+
private archivedDeleteUnavailableMessage(): string {
const machineName = this.state.selectedMachine?.name ?? "this machine";
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
@@ -1033,6 +1038,7 @@ export class PiWebApp extends LitElement {
.selectedSession=${this.state.selectedSession}
.canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
+ .canReloadSessions=${this.canReloadSessions()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout}
diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts
index c0e5785..bc78b67 100644
--- a/src/client/src/components/SessionList.ts
+++ b/src/client/src/components/SessionList.ts
@@ -31,6 +31,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) selected?: SessionInfo;
@property({ type: Boolean }) canStart = false;
@property({ type: Boolean }) canDeleteArchived = false;
+ @property({ type: Boolean }) canReload = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@@ -227,7 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`
: html`
-
+ ${this.canReload ? html`` : null}
${session.parentSessionPath !== undefined ? html`` : null}
${descendantCount > 0 ? html`` : null}
diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts
index 242987d..4b8bb77 100644
--- a/src/client/src/components/appShell/AppNavigationPanel.ts
+++ b/src/client/src/components/appShell/AppNavigationPanel.ts
@@ -41,6 +41,7 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) sessionsCollapsed = false;
@property({ type: Boolean }) canStartSession = false;
@property({ type: Boolean }) canDeleteArchivedSessions = false;
+ @property({ type: Boolean }) canReloadSessions = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ attribute: false }) onShowActions?: () => void;
@property({ attribute: false }) onToggleMachines?: () => void;
@@ -157,6 +158,7 @@ export class AppNavigationPanel extends LitElement {
.selected=${this.selectedSession}
.canStart=${this.canStartSession}
.canDeleteArchived=${this.canDeleteArchivedSessions}
+ .canReload=${this.canReloadSessions}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.collapsible=${this.collapsible}
.collapsed=${this.sessionsCollapsed}
diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts
index 52c7224..93a6bf0 100644
--- a/src/client/src/controllers/sessionController.test.ts
+++ b/src/client/src/controllers/sessionController.test.ts
@@ -487,6 +487,73 @@ describe("SessionController", () => {
expect(state.error).toContain("requires an updated Pi-Web runtime");
});
+ it("reloads the selected session, discards the cached transcript, and re-fetches history", async () => {
+ Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
+ const reloadCalls: string[] = [];
+ const messageCalls: string[] = [];
+ let state: AppState = {
+ ...initialAppState(),
+ selectedWorkspace: workspace,
+ selectedSession: oldSession,
+ sessions: [oldSession],
+ machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
+ };
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ reloadSession: (session) => {
+ reloadCalls.push(sessionLookupId(session));
+ return Promise.resolve({ reloaded: true });
+ },
+ messages: (session) => {
+ messageCalls.push(sessionLookupId(session));
+ return Promise.resolve(emptyPage);
+ },
+ status: (session) => Promise.resolve(status(sessionLookupId(session))),
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ new InMemorySessionSelectionMemory(),
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.reloadSession(oldSession);
+
+ expect(reloadCalls).toEqual([oldSession.id]);
+ expect(messageCalls).toContain(oldSession.id);
+ expect(state.error).toBe("");
+ });
+
+ it("does not reload sessions when the selected machine runtime does not support it", async () => {
+ const reloadCalls: string[] = [];
+ let state: AppState = {
+ ...initialAppState(),
+ selectedWorkspace: workspace,
+ selectedSession: oldSession,
+ sessions: [oldSession],
+ };
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ reloadSession: (session) => {
+ reloadCalls.push(sessionLookupId(session));
+ return Promise.resolve({ reloaded: true });
+ },
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ new InMemorySessionSelectionMemory(),
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.reloadSession(oldSession);
+
+ expect(reloadCalls).toEqual([]);
+ expect(state.error).toContain("requires an updated Pi-Web runtime");
+ });
+
it("forgets archived selections when the archived section collapse clears selection", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts
index ae06bfd..74ddb30 100644
--- a/src/client/src/controllers/sessionController.ts
+++ b/src/client/src/controllers/sessionController.ts
@@ -380,9 +380,15 @@ export class SessionController {
}
async reloadSession(session = this.getState().selectedSession) {
- if (session === undefined) return;
+ if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return;
+ const machineId = selectedMachineId(this.getState());
+ const runtime = this.getState().machineRuntimes[machineId];
+ if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
+ this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." });
+ return;
+ }
try {
- await this.api.reloadSession(session.id, selectedMachineId(this.getState()));
+ await this.api.reloadSession(session.id, machineId);
this.transcripts.discard(this.sessionCacheKey(session.id));
if (this.getState().selectedSession?.id === session.id) {
await this.selectSession(session, { updateUrl: false });
diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts
index a73c808..ed444a0 100644
--- a/src/server/sessions/piSessionService.test.ts
+++ b/src/server/sessions/piSessionService.test.ts
@@ -425,6 +425,74 @@ describe("PiSessionService", () => {
await service.dispose();
});
+ it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
+ const first = fakeRuntime("reload-session");
+ const second = fakeRuntime("reload-session");
+ const runtimes = [first.runtime, second.runtime];
+ let createCalls = 0;
+ const createAgentRuntime: RuntimeCreator = async () => {
+ await Promise.resolve();
+ const runtime = runtimes[createCalls];
+ createCalls += 1;
+ if (runtime === undefined) throw new Error("unexpected runtime creation");
+ return runtime;
+ };
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ createAgentRuntime,
+ sessionManager: sessionGateway([sessionRecord("reload-session")]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ // Open once so there is an active runtime to reload.
+ await service.status(sessionRef("reload-session"));
+ expect(createCalls).toBe(1);
+
+ await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined();
+
+ // The original runtime was torn down and a fresh one opened from disk.
+ expect(first.calls.abort).toBe(1);
+ expect(first.calls.dispose).toBe(1);
+ expect(createCalls).toBe(2);
+ expect(service.activeCount()).toBe(1);
+
+ await service.dispose();
+ });
+
+ it("refuses to reload a session that has active work in progress", async () => {
+ const fake = fakeRuntime("busy-session", { isStreaming: true });
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ createAgentRuntime: runtimeCreator(fake.runtime),
+ sessionManager: sessionGateway([sessionRecord("busy-session")]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading");
+ expect(fake.calls.abort).toBe(0);
+ expect(fake.calls.dispose).toBe(0);
+
+ await service.dispose();
+ });
+
+ it("refuses to reload an archived session", async () => {
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ archiveStore: {
+ list: () => Promise.resolve([]),
+ get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
+ ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
+ : undefined),
+ archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }),
+ restore: () => Promise.resolve(),
+ isArchived: () => Promise.resolve(true),
+ },
+ sessionManager: sessionGateway([]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only");
+
+ await service.dispose();
+ });
+
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index 75f271c..2049988 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -561,8 +561,10 @@ export class PiSessionService {
}
async reload(ref: PiSessionLookup): Promise {
- const active = await this.getActive(ref);
- await this.closeActive(active.runtime.session.sessionId);
+ await this.assertWritable(ref);
+ const session = await this.getOrOpen(ref);
+ if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
+ await this.closeActive(session.sessionId);
const reopened = await this.getActive(ref);
this.publishStatus(reopened.runtime.session);
}
diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts
index eda1831..7cc4ca7 100644
--- a/src/server/sessions/sessionRoutes.test.ts
+++ b/src/server/sessions/sessionRoutes.test.ts
@@ -97,15 +97,62 @@ describe("session routes", () => {
await routeApp.close();
}
});
+
+ it("reloads a session through the reload route, forwarding workspace context", async () => {
+ const routeApp = Fastify({ logger: false });
+ await routeApp.register(fastifyWebsocket);
+ const eventHub = new SessionEventHub();
+ const routeService = new CapturingRouteSessionService(eventHub);
+ registerSessionRoutes(routeApp, routeService, eventHub);
+
+ try {
+ const requestCwd = resolve("/repo");
+ const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: { cwd: requestCwd } });
+
+ expect(reloadResponse.statusCode).toBe(200);
+ expect(reloadResponse.json()).toEqual({ reloaded: true });
+ expect(routeService.reloadCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
+ } finally {
+ await routeService.dispose();
+ await routeApp.close();
+ }
+ });
+
+ it("maps reload failures to a mutation error status", async () => {
+ const routeApp = Fastify({ logger: false });
+ await routeApp.register(fastifyWebsocket);
+ const eventHub = new SessionEventHub();
+ const routeService = new CapturingRouteSessionService(eventHub);
+ routeService.reloadError = new Error("Stop current session activity before reloading");
+ registerSessionRoutes(routeApp, routeService, eventHub);
+
+ try {
+ const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: {} });
+
+ expect(reloadResponse.statusCode).toBe(400);
+ expect(reloadResponse.json()).toEqual({ error: "Stop current session activity before reloading" });
+ } finally {
+ await routeService.dispose();
+ await routeApp.close();
+ }
+ });
});
class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = [];
+ readonly reloadCalls: (string | PiSessionRef)[] = [];
+ reloadError: Error | undefined;
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
}
+ override reload(lookup: string | PiSessionRef): Promise {
+ this.reloadCalls.push(lookup);
+ if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
+ return Promise.resolve();
+ }
+
override status(lookup: string | PiSessionRef) {
this.calls.push(lookup);
return Promise.resolve({
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 3b9421c..6ce5db7 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error";
export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived",
+ sessionsReload: "sessions.reload",
promptAttachments: "prompt.attachments",
} as const;
diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts
index 4795ed3..8c5a255 100644
--- a/src/shared/capabilities.ts
+++ b/src/shared/capabilities.ts
@@ -6,11 +6,12 @@ export type { PiWebCapability };
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES);
-export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
-export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
+export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
+export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
+ [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
} as const satisfies Record;