From babb8029129eee0eb4a8584172ac121aae3c6117 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Tue, 19 May 2026 09:27:10 +0200
Subject: [PATCH] feat: add Pi Web status panel
---
.changeset/pi-web-status-updates.md | 5 +
README.md | 2 +-
docs/plugins.html | 8 +-
docs/plugins.md | 4 +-
pi-web-plugins/info/pi-web-plugin.js | 4 +-
pi-web-plugins/pi-web/package.json | 9 +
pi-web-plugins/pi-web/pi-web-plugin.js | 148 ++++++++
src/client/src/api.ts | 4 +-
src/client/src/api/clients.ts | 6 +
src/client/src/api/parsers.ts | 87 ++++-
src/client/src/appState.ts | 4 +-
src/client/src/components/PiWebApp.ts | 33 +-
src/client/src/components/WorkspacePanel.ts | 3 +
src/client/src/plugins/types.ts | 1 +
src/server/app.ts | 3 +
src/server/piWebStatus.test.ts | 47 +++
src/server/piWebStatus.ts | 386 ++++++++++++++++++++
src/server/sessiond.ts | 8 +-
src/shared/apiTypes.ts | 57 +++
19 files changed, 803 insertions(+), 16 deletions(-)
create mode 100644 .changeset/pi-web-status-updates.md
create mode 100644 pi-web-plugins/pi-web/package.json
create mode 100644 pi-web-plugins/pi-web/pi-web-plugin.js
create mode 100644 src/server/piWebStatus.test.ts
create mode 100644 src/server/piWebStatus.ts
diff --git a/.changeset/pi-web-status-updates.md b/.changeset/pi-web-status-updates.md
new file mode 100644
index 0000000..ca72fff
--- /dev/null
+++ b/.changeset/pi-web-status-updates.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add a beta-labeled Pi Web status panel with update instructions tailored to global npm, Pi package, or local installs. The panel appears for update/restart messages and stays visible for local or unknown installs, while keeping the bundled Info plugin as the minimal documented plugin example.
diff --git a/README.md b/README.md
index 4f3540a..297e0fa 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ Pi Web keeps its own state intentionally small:
Pi Web production installs can load trusted local UI plugins without rebuilding Pi Web. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed.
-The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical real example.
+The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical minimal real example, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel.
A useful prompt for AI agents:
diff --git a/docs/plugins.html b/docs/plugins.html
index cbeb28b..dfe3f66 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -140,8 +140,8 @@ After editing, check the manifest endpoint and browser-console failure cases.
Canonical example
- Pi Web ships a real bundled Info plugin. It is the reference example because it uses all
- current contribution types: one action, one workspace label, and one workspace panel.
+ Pi Web ships a real bundled Info plugin. It is intentionally small while still using all
+ core contribution types: one action, one workspace label, and one workspace panel.
pi-web-plugins/info/package.json shows the required metadata shape.
@@ -152,6 +152,10 @@ After editing, check the manifest endpoint and browser-console failure cases.pi-web-plugins/info.
If you copy it, choose a new plugin id so it does not conflict with the bundled info plugin.
+
+ The bundled pi-web status plugin demonstrates dynamic visible and badge
+ callbacks for tabs that only appear when the host has status messages or needs extra install visibility.
+
diff --git a/docs/plugins.md b/docs/plugins.md
index ebbebbc..aeca357 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -63,7 +63,7 @@ After editing, check the manifest endpoint and browser-console failure cases.
## Canonical example: bundled Info plugin
-Pi Web ships a real bundled `info` plugin. Use it as the reference example because it exercises all current contribution types: an action, a workspace label, and a workspace panel.
+Pi Web ships a real bundled `info` plugin. Use it as the reference example because it is intentionally small while still exercising all core contribution types: an action, a workspace label, and a workspace panel.
Files:
@@ -104,6 +104,8 @@ export default {
When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin.
+Pi Web also ships a `pi-web` status plugin that demonstrates dynamic `visible` and `badge` callbacks for tabs that only appear when the host has status messages or needs extra install visibility.
+
## Local plugin usage
This works with the production npm/systemd install. Pi Web discovers plugins from `~/.pi-web/plugins//` on the web/API side; no Pi Web rebuild or session-daemon restart is required. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead.
diff --git a/pi-web-plugins/info/pi-web-plugin.js b/pi-web-plugins/info/pi-web-plugin.js
index 5d0b4a1..ec24cc2 100644
--- a/pi-web-plugins/info/pi-web-plugin.js
+++ b/pi-web-plugins/info/pi-web-plugin.js
@@ -17,7 +17,7 @@ export default {
],
workspaceLabels: [
{
- id: "workspace.path-label",
+ id: "workspace.kind-label",
order: 100,
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
},
@@ -26,7 +26,7 @@ export default {
{
id: "workspace.info",
title: "Info",
- order: 100,
+ order: 1000,
render: (context) => html`
diff --git a/pi-web-plugins/pi-web/package.json b/pi-web-plugins/pi-web/package.json
new file mode 100644
index 0000000..136c5cf
--- /dev/null
+++ b/pi-web-plugins/pi-web/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@pi-web/status-plugin",
+ "private": true,
+ "piWeb": {
+ "plugins": [
+ { "id": "pi-web", "module": "pi-web-plugin.js" }
+ ]
+ }
+}
diff --git a/pi-web-plugins/pi-web/pi-web-plugin.js b/pi-web-plugins/pi-web/pi-web-plugin.js
new file mode 100644
index 0000000..ac6d31a
--- /dev/null
+++ b/pi-web-plugins/pi-web/pi-web-plugin.js
@@ -0,0 +1,148 @@
+function messagesFor(state) {
+ return state?.piWebStatus?.messages ?? [];
+}
+
+function statusFor(state) {
+ return state?.piWebStatus;
+}
+
+function messageCount(state) {
+ return messagesFor(state).length;
+}
+
+function isLocalOrUnknownInstallation(installation) {
+ return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
+}
+
+function shouldShowStatusPanel(state) {
+ const status = statusFor(state);
+ if (messageCount(state) > 0) return true;
+ if (status === undefined) return false;
+ return isLocalOrUnknownInstallation(status.components.web.installation)
+ || isLocalOrUnknownInstallation(status.components.sessiond.installation);
+}
+
+function formatVersion(version) {
+ return version === undefined || version === "" ? "unknown" : version;
+}
+
+function installationLabel(installation) {
+ if (installation === undefined) return "installation unknown";
+ if (installation.kind === "pi-package") {
+ const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
+ const source = installation.source === undefined ? "Pi package" : installation.source;
+ return `${source}${scope}`;
+ }
+ if (installation.kind === "npm-global") return "global npm package";
+ if (installation.kind === "local") return "local checkout";
+ return "installation unknown";
+}
+
+function renderComponent(html, component) {
+ const status = component.available === false
+ ? "unavailable"
+ : component.stale
+ ? "restart needed"
+ : "current";
+ return html`
+
+ ${component.label}
+ ${status}
+ running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)}
+ ${installationLabel(component.installation)}${component.installation?.path === undefined ? "" : ` · ${component.installation.path}`}
+
+ `;
+}
+
+function renderCommand(html, label, command) {
+ return html`
+
+ ${label}
+ ${command}
+ { void navigator.clipboard?.writeText(command); }}>Copy
+
+ `;
+}
+
+function renderStatusPanel(html, state) {
+ const status = statusFor(state);
+ if (status === undefined) {
+ return html`
+
+
+ `;
+ }
+
+ const messages = status.messages;
+ return html`
+
+
+
+
+ ${messages.length === 0 ? html`No Pi Web update or restart messages.
` : messages.map((message) => html`
+
+ ${message.title} ${message.severity}
+ ${message.body}
+ ${message.command === undefined ? null : html`${message.command}`}
+
+ `)}
+
+
+
+ Installed services
+ ${renderComponent(html, status.components.web)}
+ ${renderComponent(html, status.components.sessiond)}
+
+
+
+ Commands
+ ${renderCommand(html, "Update", status.commands.update)}
+ ${renderCommand(html, "Restart", status.commands.restart)}
+ ${renderCommand(html, "systemd", status.commands.restartSystemd)}
+ ${renderCommand(html, "dev", status.commands.restartDev)}
+
+
+
+
+ `;
+}
+
+export default {
+ apiVersion: 1,
+ name: "Pi Web Status",
+ activate: ({ html }) => ({
+ contributions: {
+ workspacePanels: [
+ {
+ id: "workspace.status",
+ title: "Pi Web",
+ order: 100,
+ visible: (context) => shouldShowStatusPanel(context.state),
+ badge: (context) => {
+ const count = messageCount(context.state);
+ return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
+ },
+ render: (context) => renderStatusPanel(html, context.state),
+ },
+ ],
+ },
+ }),
+};
diff --git a/src/client/src/api.ts b/src/client/src/api.ts
index 47b0324..16ef81c 100644
--- a/src/client/src/api.ts
+++ b/src/client/src/api.ts
@@ -1,3 +1,3 @@
-export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
+export { api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
-export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
+export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts
index 556588f..ecd9d99 100644
--- a/src/client/src/api/clients.ts
+++ b/src/client/src/api/clients.ts
@@ -17,6 +17,7 @@ import {
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
+ parsePiWebStatusResponse,
parseProject,
parseRestored,
parseSessionInfo,
@@ -29,6 +30,10 @@ import {
} from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls";
+export const piWebApi = {
+ piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
+};
+
export const projectsApi = {
projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
@@ -94,6 +99,7 @@ export const gitApi = {
};
export const api = {
+ ...piWebApi,
...projectsApi,
...workspacesApi,
...sessionsApi,
diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts
index 2edf19b..2494ec1 100644
--- a/src/client/src/api/parsers.ts
+++ b/src/client/src/api/parsers.ts
@@ -1,4 +1,4 @@
-import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
+import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
@@ -298,6 +298,91 @@ export function parseTerminalInfo(value: unknown): TerminalInfo {
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
}
+export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
+ const record = requireRecord(value);
+ return {
+ packageName: requireString(record, "packageName"),
+ generatedAt: requireString(record, "generatedAt"),
+ components: parsePiWebComponents(record["components"]),
+ release: parsePiWebReleaseStatus(record["release"]),
+ commands: parsePiWebCommands(record["commands"]),
+ messages: arrayOf(parsePiWebStatusMessage)(record["messages"]),
+ };
+}
+
+function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
+ const record = requireRecord(value);
+ return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
+}
+
+function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
+ const record = requireRecord(value);
+ return {
+ component: parsePiWebServiceComponent(record["component"]),
+ label: requireString(record, "label"),
+ ...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
+ ...optionalField("installedVersion", optionalString(record, "installedVersion")),
+ stale: requireBoolean(record, "stale"),
+ available: requireBoolean(record, "available"),
+ ...optionalField("installation", optionalPiWebInstallationInfo(record["installation"])),
+ ...optionalField("error", optionalString(record, "error")),
+ };
+}
+
+function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
+ if (value === undefined) return undefined;
+ const record = requireRecord(value);
+ const kind = requireString(record, "kind");
+ if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid Pi Web installation kind");
+ const scope = record["scope"];
+ if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid Pi Web installation scope");
+ return {
+ kind,
+ ...optionalField("path", optionalString(record, "path")),
+ ...optionalField("source", optionalString(record, "source")),
+ ...(scope === undefined ? {} : { scope }),
+ ...optionalField("npmRoot", optionalString(record, "npmRoot")),
+ };
+}
+
+function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
+ const record = requireRecord(value);
+ return {
+ packageName: requireString(record, "packageName"),
+ ...optionalField("latestVersion", optionalString(record, "latestVersion")),
+ updateAvailable: requireBoolean(record, "updateAvailable"),
+ ...optionalField("checkedAt", optionalString(record, "checkedAt")),
+ ...(record["skipped"] === true ? { skipped: true } : {}),
+ ...optionalField("error", optionalString(record, "error")),
+ };
+}
+
+function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
+ const record = requireRecord(value);
+ return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
+}
+
+function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
+ const record = requireRecord(value);
+ return {
+ id: requireString(record, "id"),
+ severity: parsePiWebStatusSeverity(record["severity"]),
+ title: requireString(record, "title"),
+ body: requireString(record, "body"),
+ ...optionalField("command", optionalString(record, "command")),
+ };
+}
+
+function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
+ if (value !== "web" && value !== "sessiond") throw new Error("Invalid Pi Web service component");
+ return value;
+}
+
+function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
+ if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid Pi Web status severity");
+ return value;
+}
+
export function parseCommandResult(value: unknown): CommandResult {
const record = requireRecord(value);
const type = requireString(record, "type");
diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts
index c5fcfd2..6d3515c 100644
--- a/src/client/src/appState.ts
+++ b/src/client/src/appState.ts
@@ -1,4 +1,4 @@
-import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
+import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -38,6 +38,7 @@ export interface AppState {
selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean;
activeTerminalCount: number;
+ piWebStatus: PiWebStatusResponse | undefined;
error: string;
}
@@ -85,6 +86,7 @@ export function initialAppState(): AppState {
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
+ piWebStatus: undefined,
error: "",
};
}
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 35ee626..90deec0 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
-import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
+import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { AuthController } from "../controllers/authController";
@@ -36,6 +36,8 @@ import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
+const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
+
@customElement("pi-web-app")
export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@@ -78,15 +80,22 @@ export class PiWebApp extends LitElement {
private readonly activeTerminalIds = new Set();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined;
+ private piWebStatusTimer: number | undefined;
private readonly plugins = createPluginRegistry();
private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID;
@state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
- private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); };
+ private readonly onFocus = () => {
+ void this.sessions.refreshSelectedSession();
+ void this.refreshPiWebStatus();
+ };
private readonly onVisibilityChange = () => {
- if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession();
+ if (document.visibilityState === "visible") {
+ void this.sessions.refreshSelectedSession();
+ void this.refreshPiWebStatus();
+ }
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
@@ -107,6 +116,8 @@ export class PiWebApp extends LitElement {
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.applyPreferredTheme(false);
this.connectRealtime();
+ this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
+ void this.refreshPiWebStatus();
void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute();
}
@@ -122,6 +133,8 @@ export class PiWebApp extends LitElement {
this.sessions.dispose();
this.realtime.close();
this.git.dispose();
+ if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
+ this.piWebStatusTimer = undefined;
super.disconnectedCallback();
}
@@ -138,6 +151,14 @@ export class PiWebApp extends LitElement {
await this.withChatScrollTransition(() => this.restoreRoute(false));
}
+ private async refreshPiWebStatus(): Promise {
+ try {
+ this.setState({ piWebStatus: await piWebApi.piWebStatus() });
+ } catch (error) {
+ console.warn("Failed to refresh Pi Web status", error);
+ }
+ }
+
private async restoreRoute(updateUrl: boolean) {
const route = readRoute();
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
@@ -268,7 +289,7 @@ export class PiWebApp extends LitElement {
private renderWorkspacePanel() {
const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace);
- return html` { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}> `;
+ return html` { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}> `;
}
private renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -351,7 +372,8 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace;
- return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true));
+ if (workspace === undefined) return [];
+ return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.({ workspace, state: this.state }) ?? true);
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
@@ -365,6 +387,7 @@ export class PiWebApp extends LitElement {
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
return {
workspace,
+ state: this.state,
fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath,
diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts
index a21eaae..3d17b68 100644
--- a/src/client/src/components/WorkspacePanel.ts
+++ b/src/client/src/components/WorkspacePanel.ts
@@ -1,6 +1,7 @@
import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
+import type { AppState } from "../appState";
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel";
@@ -8,6 +9,7 @@ import { renderWorkspaceLabel } from "./workspaceLabel";
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
+ @property({ attribute: false }) appState!: AppState;
@property() tool: QualifiedContributionId = "core:workspace.files";
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
@@ -65,6 +67,7 @@ export class WorkspacePanel extends LitElement {
private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return {
workspace,
+ state: this.appState,
fileTree: this.fileTree,
expandedDirs: this.expandedDirs,
selectedFilePath: this.selectedFilePath,
diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts
index b0dc1b4..e1ad6f0 100644
--- a/src/client/src/plugins/types.ts
+++ b/src/client/src/plugins/types.ts
@@ -75,6 +75,7 @@ export interface WorkspacePanelVisibilityContext {
export interface WorkspacePanelContext {
workspace: Workspace;
+ state: AppState;
fileTree: FileTreeEntry[];
expandedDirs: Record;
selectedFilePath: string | undefined;
diff --git a/src/server/app.ts b/src/server/app.ts
index 9ecd352..e35d503 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -14,6 +14,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
+import { getPiWebStatus } from "./piWebStatus.js";
export interface AppDependencies {
projects?: ProjectService;
@@ -39,6 +40,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus());
+
app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts
new file mode 100644
index 0000000..e6a82f6
--- /dev/null
+++ b/src/server/piWebStatus.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js";
+import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+
+const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
+
+afterEach(() => {
+ if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"];
+ else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck;
+ vi.restoreAllMocks();
+});
+
+describe("Pi Web status", () => {
+ it("compares semver-shaped CalVer versions", () => {
+ expect(comparePackageVersions("1.202605.9", "1.202605.8")).toBeGreaterThan(0);
+ expect(comparePackageVersions("1.202605.8", "1.202605.8")).toBe(0);
+ expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0);
+ });
+
+ it("reports stale session daemon versions as messages", async () => {
+ process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
+ const daemon = new SessionDaemonClient();
+ vi.spyOn(daemon, "request").mockResolvedValue({
+ statusCode: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ version: {
+ component: "sessiond",
+ label: "Session daemon",
+ runtimeVersion: "1.202605.7",
+ installedVersion: "1.202605.8",
+ stale: true,
+ available: true,
+ installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
+ },
+ }),
+ });
+
+ const status = await getPiWebStatus(daemon);
+
+ expect(status.release.skipped).toBe(true);
+ expect(status.components.sessiond.stale).toBe(true);
+ expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" });
+ expect(status.commands.update).not.toBe("");
+ expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
+ });
+});
diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts
new file mode 100644
index 0000000..a4a39a9
--- /dev/null
+++ b/src/server/piWebStatus.ts
@@ -0,0 +1,386 @@
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { readFile, realpath, stat } from "node:fs/promises";
+import { dirname, join, relative, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js";
+import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+
+const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
+const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
+const DEFAULT_VERSION = "0.0.0-dev";
+const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
+const VERSION_CHECK_TIMEOUT_MS = 5000;
+
+const restartCommands = {
+ restart: "pi-web restart",
+ restartSystemd: "systemctl --user restart pi-web-sessiond.service pi-web.service",
+ restartDev: "systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service",
+};
+
+interface PackageInfo {
+ name: string;
+ version: string;
+ path: string;
+}
+
+let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
+
+const runtimePackageInfo = readPackageInfoSync();
+
+export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise {
+ const [installed, installation] = await Promise.all([
+ readInstalledPackageInfo(),
+ detectPiWebInstallation(),
+ ]);
+ const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
+ const installedVersion = installed?.version;
+ return {
+ component,
+ label: component === "web" ? "Web/UI" : "Session daemon",
+ runtimeVersion,
+ ...(installedVersion === undefined ? {} : { installedVersion }),
+ stale: isInstalledVersionNewer(installedVersion, runtimeVersion),
+ available: true,
+ installation,
+ };
+}
+
+export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise {
+ const web = await getPiWebComponentStatus("web");
+ const [installed, sessiond] = await Promise.all([
+ readInstalledPackageInfo(),
+ getSessiondComponentStatus(daemon),
+ ]);
+ const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION);
+ const components = { web, sessiond };
+ const commands = commandsFor(web.installation ?? sessiond.installation);
+ const messages = buildMessages(components, release, commands);
+ return {
+ packageName: PI_WEB_PACKAGE_NAME,
+ generatedAt: new Date().toISOString(),
+ components,
+ release,
+ commands,
+ messages,
+ };
+}
+
+export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined {
+ const left = parsePackageVersion(leftVersion);
+ const right = parsePackageVersion(rightVersion);
+ if (left === undefined || right === undefined) return undefined;
+ if (left.major !== right.major) return left.major - right.major;
+ if (left.minor !== right.minor) return left.minor - right.minor;
+ if (left.patch !== right.patch) return left.patch - right.patch;
+ if (left.prerelease === right.prerelease) return 0;
+ if (left.prerelease === undefined) return 1;
+ if (right.prerelease === undefined) return -1;
+ return left.prerelease.localeCompare(right.prerelease);
+}
+
+function readPackageInfoSync(): PackageInfo | undefined {
+ const path = packageJsonPath();
+ try {
+ return parsePackageInfo(JSON.parse(readFileSync(path, "utf8")), path);
+ } catch {
+ return undefined;
+ }
+}
+
+async function readInstalledPackageInfo(): Promise {
+ const path = packageJsonPath();
+ try {
+ await stat(path);
+ return parsePackageInfo(JSON.parse(await readFile(path, "utf8")), path);
+ } catch {
+ return undefined;
+ }
+}
+
+function packageJsonPath(): string {
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
+}
+
+function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined {
+ if (!isRecord(value)) return undefined;
+ const name = value["name"];
+ const version = value["version"];
+ if (typeof name !== "string" || name === "" || typeof version !== "string" || version === "") return undefined;
+ return { name, version, path };
+}
+
+async function detectPiWebInstallation(): Promise {
+ const root = packageRootPath();
+ const realRoot = await realPathOrSelf(root);
+ const piPackage = await detectPiPackageInstallation(realRoot, root);
+ if (piPackage !== undefined) return piPackage;
+ const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
+ if (npmGlobal !== undefined) return npmGlobal;
+ return { kind: "local", path: root };
+}
+
+async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise {
+ try {
+ const agentDir = getAgentDir();
+ const packageManager = new DefaultPackageManager({
+ cwd: process.cwd(),
+ agentDir,
+ settingsManager: SettingsManager.create(process.cwd(), agentDir),
+ });
+ for (const configuredPackage of packageManager.listConfiguredPackages()) {
+ const installedPath = configuredPackage.installedPath ?? packageManager.getInstalledPath(configuredPackage.source, configuredPackage.scope);
+ if (installedPath === undefined) continue;
+ const realInstalledPath = await realPathOrSelf(installedPath);
+ if (isSameOrWithin(realInstalledPath, realRoot) || isSameOrWithin(realRoot, realInstalledPath)) {
+ return { kind: "pi-package", path: displayPath, source: configuredPackage.source, scope: configuredPackage.scope };
+ }
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
+}
+
+async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise {
+ const npmRoot = npmGlobalRoot();
+ if (npmRoot === undefined) return undefined;
+ const realNpmRoot = await realPathOrSelf(npmRoot);
+ if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
+ return { kind: "npm-global", path: displayPath, npmRoot };
+}
+
+function npmGlobalRoot(): string | undefined {
+ const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
+ if (result.status !== 0) return undefined;
+ const root = result.stdout.trim();
+ return root === "" ? undefined : root;
+}
+
+function packageRootPath(): string {
+ return dirname(packageJsonPath());
+}
+
+async function realPathOrSelf(path: string): Promise {
+ return realpath(path).catch(() => resolve(path));
+}
+
+function isSameOrWithin(parent: string, candidate: string): boolean {
+ const rel = relative(parent, candidate);
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
+}
+
+async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise {
+ try {
+ const upstream = await daemon.request("GET", "/health");
+ if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
+ return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`);
+ }
+ const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
+ const version = isRecord(parsed) ? parsed["version"] : undefined;
+ const component = parseComponentStatus(version);
+ return component ?? unavailableSessiond("health response did not include version information");
+ } catch (error) {
+ return unavailableSessiond(error instanceof Error ? error.message : String(error));
+ }
+}
+
+function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined {
+ if (!isRecord(value)) return undefined;
+ const component = value["component"];
+ const label = value["label"];
+ const runtimeVersion = value["runtimeVersion"];
+ const installedVersion = value["installedVersion"];
+ const stale = value["stale"];
+ const available = value["available"];
+ const error = value["error"];
+ const installation = parseInstallationInfo(value["installation"]);
+ if (component !== "web" && component !== "sessiond") return undefined;
+ if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
+ return {
+ component,
+ label,
+ ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
+ ...(typeof installedVersion === "string" ? { installedVersion } : {}),
+ stale,
+ available,
+ ...(installation === undefined ? {} : { installation }),
+ ...(typeof error === "string" ? { error } : {}),
+ };
+}
+
+function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
+ if (!isRecord(value)) return undefined;
+ const kind = value["kind"];
+ const path = value["path"];
+ const source = value["source"];
+ const scope = value["scope"];
+ const npmRoot = value["npmRoot"];
+ if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
+ return {
+ kind,
+ ...(typeof path === "string" ? { path } : {}),
+ ...(typeof source === "string" ? { source } : {}),
+ ...(scope === "user" || scope === "project" ? { scope } : {}),
+ ...(typeof npmRoot === "string" ? { npmRoot } : {}),
+ };
+}
+
+function unavailableSessiond(error: string): PiWebComponentStatus {
+ return {
+ component: "sessiond",
+ label: "Session daemon",
+ stale: false,
+ available: false,
+ error,
+ };
+}
+
+async function getLatestReleaseStatus(currentVersion: string): Promise {
+ const checkedAtMs = Date.now();
+ if (skipVersionCheck()) {
+ return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
+ }
+
+ if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
+ return releaseStatusFromCache(latestReleaseCache, currentVersion);
+ }
+
+ try {
+ latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
+ } catch (error) {
+ latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
+ }
+ return releaseStatusFromCache(latestReleaseCache, currentVersion);
+}
+
+function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
+ return {
+ packageName: PI_WEB_PACKAGE_NAME,
+ ...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
+ updateAvailable: cache.latestVersion === undefined ? false : isNewerPackageVersion(cache.latestVersion, currentVersion),
+ checkedAt: new Date(cache.checkedAtMs).toISOString(),
+ ...(cache.error === undefined ? {} : { error: cache.error }),
+ };
+}
+
+async function fetchLatestNpmVersion(currentVersion: string): Promise {
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PI_WEB_PACKAGE_NAME)}/latest`, {
+ headers: {
+ accept: "application/json",
+ "user-agent": `${PI_WEB_PACKAGE_NAME}/${currentVersion}`,
+ },
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
+ });
+ if (!response.ok) throw new Error(`npm registry returned HTTP ${String(response.status)}`);
+ const data: unknown = await response.json();
+ const version = isRecord(data) ? data["version"] : undefined;
+ if (typeof version !== "string" || version === "") throw new Error("npm registry response did not include a version");
+ return version;
+}
+
+function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] {
+ return {
+ update: updateCommandFor(installation),
+ ...restartCommands,
+ };
+}
+
+function updateCommandFor(installation: PiWebInstallationInfo | undefined): string {
+ if (installation?.kind === "pi-package") {
+ return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restartSystemd}`;
+ }
+ if (installation?.kind === "local" && installation.path !== undefined) {
+ return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restartSystemd}`;
+ }
+ return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`;
+}
+
+function shellQuote(value: string): string {
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+function buildMessages(components: PiWebStatusResponse["components"], release: PiWebReleaseStatus, commands: PiWebStatusResponse["commands"]): PiWebStatusMessage[] {
+ const messages: PiWebStatusMessage[] = [];
+ const installedVersion = components.web.installedVersion ?? components.web.runtimeVersion;
+
+ if (release.updateAvailable && release.latestVersion !== undefined) {
+ messages.push({
+ id: "update-available",
+ severity: "info",
+ title: "Pi Web update available",
+ body: `Pi Web ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update Pi Web, then restart Pi Web services.`,
+ command: commands.update,
+ });
+ }
+
+ if (components.web.stale) {
+ messages.push({
+ id: "web-stale",
+ severity: "warning",
+ title: "Web/UI service restart needed",
+ body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
+ command: commands.restart,
+ });
+ }
+
+ if (!components.sessiond.available) {
+ messages.push({
+ id: "sessiond-unavailable",
+ severity: "warning",
+ title: "Session daemon version unavailable",
+ body: `Pi Web could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
+ command: "systemctl --user status pi-web-sessiond.service",
+ });
+ } else if (components.sessiond.stale) {
+ messages.push({
+ id: "sessiond-stale",
+ severity: "warning",
+ title: "Session daemon restart needed",
+ body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
+ command: commands.restart,
+ });
+ }
+
+ return messages;
+}
+
+function skipVersionCheck(): boolean {
+ return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => {
+ const value = process.env[key];
+ return value !== undefined && value !== "";
+ });
+}
+
+function isInstalledVersionNewer(installedVersion: string | undefined, runtimeVersion: string | undefined): boolean {
+ if (installedVersion === undefined || runtimeVersion === undefined) return false;
+ return isNewerPackageVersion(installedVersion, runtimeVersion);
+}
+
+function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean {
+ const comparison = comparePackageVersions(candidateVersion, currentVersion);
+ if (comparison !== undefined) return comparison > 0;
+ return candidateVersion.trim() !== currentVersion.trim();
+}
+
+function parsePackageVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | undefined {
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/u.exec(version.trim());
+ if (match === null) return undefined;
+ const [, major, minor, patch, prerelease] = match;
+ if (major === undefined || minor === undefined || patch === undefined) return undefined;
+ return {
+ major: Number.parseInt(major, 10),
+ minor: Number.parseInt(minor, 10),
+ patch: Number.parseInt(patch, 10),
+ ...(prerelease === undefined ? {} : { prerelease }),
+ };
+}
+
+function formatVersion(version: string | undefined): string {
+ return version ?? "unknown";
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index 55a657d..63fd231 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -11,6 +11,7 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { sessiondSocketPath } from "./sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
+import { getPiWebComponentStatus } from "./piWebStatus.js";
const app = Fastify({ logger: true });
await app.register(fastifyWebsocket);
@@ -24,7 +25,12 @@ registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);
-app.get("/health", () => ({ ok: true, activeSessions: sessions.activeCount(), checkedAt: new Date().toISOString() }));
+app.get("/health", async () => ({
+ ok: true,
+ activeSessions: sessions.activeCount(),
+ checkedAt: new Date().toISOString(),
+ version: await getPiWebComponentStatus("sessiond"),
+}));
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise {
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index f3b89bc..a9b77f8 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -180,6 +180,63 @@ export interface TerminalInfo {
exitCode?: number;
}
+export type PiWebServiceComponent = "web" | "sessiond";
+export type PiWebStatusSeverity = "info" | "warning" | "error";
+export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "unknown";
+
+export interface PiWebInstallationInfo {
+ kind: PiWebInstallationKind;
+ path?: string;
+ source?: string;
+ scope?: "user" | "project";
+ npmRoot?: string;
+}
+
+export interface PiWebComponentStatus {
+ component: PiWebServiceComponent;
+ label: string;
+ runtimeVersion?: string;
+ installedVersion?: string;
+ stale: boolean;
+ available: boolean;
+ installation?: PiWebInstallationInfo;
+ error?: string;
+}
+
+export interface PiWebReleaseStatus {
+ packageName: string;
+ latestVersion?: string;
+ updateAvailable: boolean;
+ checkedAt?: string;
+ skipped?: boolean;
+ error?: string;
+}
+
+export interface PiWebStatusMessage {
+ id: string;
+ severity: PiWebStatusSeverity;
+ title: string;
+ body: string;
+ command?: string;
+}
+
+export interface PiWebStatusResponse {
+ packageName: string;
+ generatedAt: string;
+ components: {
+ web: PiWebComponentStatus;
+ sessiond: PiWebComponentStatus;
+ };
+ release: PiWebReleaseStatus;
+ commands: {
+ update: string;
+ restart: string;
+ restartSystemd: string;
+ restartDev: string;
+ };
+ messages: PiWebStatusMessage[];
+}
+
export type TerminalUiEvent =
| { type: "terminal.created"; terminal: TerminalInfo }
| { type: "terminal.exited"; terminal: TerminalInfo }