feat: add session runtime reload command

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 19:32:52 +02:00
parent 8ade238228
commit 889672ff53
19 changed files with 129 additions and 31 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add `/reload` support for PI WEB sessions so installed Pi package resources can be refreshed in existing sessions without restarting the session daemon, while keeping browser plugin reload guidance separate.
+2
View File
@@ -107,6 +107,8 @@ PI WEB supports trusted browser-side PI WEB plugins that can add actions, worksp
Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. Use **Settings → PI WEB plugins** only to enable or disable discovered browser plugins.
After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins.
Read more: [Plugin API](https://pi-web.dev/plugins)
## Configuration
+1 -1
View File
@@ -143,7 +143,7 @@
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, reload the browser page for newly discovered PI WEB browser plugins and reload existing Pi sessions (or use <code>/reload</code> in Pi) for session-runtime resources. A routine session daemon restart is not required.</li>
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, type <code>/reload</code> in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul>
</section>
+1 -1
View File
@@ -37,7 +37,7 @@ Process restarts depend on the key:
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
- Pi package install/remove/update: not a PI WEB config key; after a mutation, reload the browser page for newly discovered PI WEB browser plugins and reload existing Pi sessions (or use `/reload` in Pi) for session-runtime resources. A routine session daemon restart is not required.
- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
- `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example
+7 -6
View File
@@ -130,8 +130,8 @@
<h2>Pi packages vs PI WEB plugins</h2>
<p>
<strong>Pi packages</strong> are packages managed by Pi (<code>pi install</code>, <code>pi remove</code>,
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes, and/or
PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes,
context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
</p>
<p>
<strong>PI WEB plugins</strong> are browser-side UI modules discovered from bundled, local, dev, and
@@ -146,10 +146,11 @@
</p>
<p>
Use <strong>Settings → PI WEB plugins</strong> to enable or disable discovered PI WEB browser plugins
before the browser imports them. After installing, removing, or updating a Pi package, reload the browser
page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use
<code>/reload</code> in Pi, so extensions, skills, prompt templates, and themes are rediscovered. A
routine session daemon restart is not required.
before the browser imports them. After installing, removing, or updating a Pi package, type
<code>/reload</code> in each idle PI WEB session to refresh Pi runtime resources such as extensions,
skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser
page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart
is not required.
</p>
</section>
+2 -2
View File
@@ -15,13 +15,13 @@ They do **not** run in the session daemon, do not get a server-side hook API, an
## Pi packages vs PI WEB plugins
**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task.
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. After installing, removing, or updating a Pi package, reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use `/reload` in Pi, so session-runtime resources such as extensions, skills, prompt templates, and themes are rediscovered. A routine session daemon restart is not required.
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
## Trust model
+1 -1
View File
@@ -240,7 +240,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
`}
</div>
` : null}
@@ -23,7 +23,7 @@ export class SettingsPackagesPanel extends LitElement {
<div class="section-heading">
<div>
<h2>Pi packages</h2>
<p>Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, and PI WEB browser plugins.</p>
<p>Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.</p>
</div>
<button class="secondary" ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
@@ -37,8 +37,9 @@ describe("Pi package settings helpers", () => {
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
expect(message).toContain("Reload the browser page");
expect(message).toContain("Reload existing Pi sessions");
expect(message).toContain("Type /reload in each idle PI WEB session");
expect(message).toContain("extensions, skills, prompt templates, themes, and context/system prompt files");
expect(message).toContain("Reload the browser page separately for PI WEB browser plugin changes");
expect(message).not.toContain("session daemon");
expect(message).not.toContain("sessiond");
});
@@ -54,5 +54,5 @@ export function isPiPackageOperationPending(operation: PiPackageOperationState |
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction): string {
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
return `Pi package ${verb}. Reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use /reload in Pi, so extensions, skills, prompt templates, and themes are rediscovered.`;
return `Pi package ${verb}. Type /reload in each idle PI WEB session to rediscover Pi runtime resources: extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes.`;
}
@@ -879,7 +879,7 @@ 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 () => {
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
const reloadCalls: string[] = [];
const messageCalls: string[] = [];
@@ -917,7 +917,7 @@ describe("SessionController", () => {
expect(state.error).toBe("");
});
it("does not reload sessions when the selected machine runtime does not support it", async () => {
it("does not reload sessions from disk when the selected machine runtime does not support it", async () => {
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
@@ -943,7 +943,7 @@ describe("SessionController", () => {
await controller.reloadSession(oldSession);
expect(reloadCalls).toEqual([]);
expect(state.error).toContain("requires an updated Pi-Web runtime");
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
});
it("forgets archived selections when the archived section collapse clears selection", async () => {
@@ -484,7 +484,7 @@ export class SessionController {
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." });
this.setState({ error: "Reloading sessions from disk requires an updated Pi-Web runtime on this machine." });
return;
}
try {
+3 -3
View File
@@ -177,8 +177,8 @@ export function createCoreActions(): PluginAction[] {
},
{
id: "session.reload",
title: "Reload Session",
description: "Re-read the selected session from disk to pick up entries written by another process",
title: "Reload Session from Disk",
description: "Close and re-open the selected session from its session file. Use /reload in the prompt for Pi runtime resources.",
group: "Session",
enabled: hasReloadableSession,
disabledReason: reloadSessionDisabledReason,
@@ -236,7 +236,7 @@ function reloadSessionDisabledReason(context: { state: AppState }): string | und
const session = context.state.selectedSession;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions");
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
}
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
+6 -3
View File
@@ -187,18 +187,21 @@ describe("PluginRegistry", () => {
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
});
it("enables session reload only for a writable session on a capable, idle runtime", () => {
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 reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
expect(reloadableAction?.enabled).toBe(true);
expect(reloadableAction?.title).toBe("Reload Session from Disk");
expect(reloadableAction?.description).toContain("Use /reload in the prompt for Pi runtime resources");
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context);
const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload");
expect(noCapabilityReload?.enabled).toBe(false);
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions.");
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions from disk.");
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
+1 -1
View File
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
{ name: "new", description: "Start a new session", source: "builtin" },
{ name: "compact", description: "Manually compact session context", source: "builtin" },
{ name: "resume", description: "Resume a different session", source: "builtin" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
{ name: "quit", description: "Quit pi", source: "builtin" },
];
+28 -1
View File
@@ -63,7 +63,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -94,6 +94,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
},
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
reload: () => {
calls.reload += 1;
return Promise.resolve();
},
prompt: (text: string, options: unknown) => {
calls.prompt.push({ text, options });
return Promise.resolve();
@@ -738,6 +742,29 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(fake.calls.reload).toBe(1);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
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");
+18
View File
@@ -217,6 +217,7 @@ export interface PiAgentSession {
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
reload(): Promise<void>;
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
@@ -423,6 +424,7 @@ export class PiSessionService {
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
this.publishStatus(session);
},
reloadSession: (session) => this.reloadSessionRuntime(session),
},
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
);
@@ -1118,6 +1120,22 @@ export class PiSessionService {
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
}
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active");
try {
await session.reload();
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishStatus(session);
throw error;
}
}
async archive(ref: PiSessionLookup): Promise<void> {
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
@@ -106,6 +106,30 @@ describe("SessionCommandService", () => {
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
});
it("reloads runtime resources through the injected lifecycle callback", async () => {
const active = activeSession();
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(reloadSession).toHaveBeenCalledWith(active.runtime.session);
});
it("rejects runtime reload while the session has active work", async () => {
const active = activeSession({ isBashRunning: true });
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "unsupported",
message: "Cannot reload while the session is active. Stop current activity before reloading.",
});
expect(reloadSession).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
+21 -4
View File
@@ -47,6 +47,12 @@ export interface CommandEventPublisher {
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
}
export interface SessionCommandLifecycle<TSession extends CommandSession = CommandSession> {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
}
export interface SessionCommandNaming {
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
}
@@ -65,10 +71,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
private readonly getActive: GetCommandActiveSession<TSession>,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
private readonly events: CommandEventPublisher,
private readonly lifecycle: {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
} = {},
private readonly lifecycle: SessionCommandLifecycle<TSession> = {},
private readonly naming: SessionCommandNaming = {},
) {}
@@ -93,6 +96,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "session") return { type: "done", message: formatSessionStats(session) };
if (name === "name") return this.nameSession(active, rest);
if (name === "compact") return this.compact(session, rest);
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
@@ -140,6 +144,19 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
return { type: "done", message: "Compaction started…" };
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
await this.lifecycle.reloadSession(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Reload failed: ${message}` };
}
return { type: "done", message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes." };
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const leafId = active.runtime.session.sessionManager.getLeafId();