From c6c030ab26d6dd647e53a30cc3d6fa8b5b4f812e Mon Sep 17 00:00:00 2001
From: Davy <95214375+thedavidweng@users.noreply.github.com>
Date: Sat, 13 Jun 2026 17:46:56 -0700
Subject: [PATCH] feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.
New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
---
packages/app/src/i18n/resources/ar.ts | 24 +++
packages/app/src/i18n/resources/en.ts | 24 +++
packages/app/src/i18n/resources/es.ts | 24 +++
packages/app/src/i18n/resources/fr.ts | 24 +++
packages/app/src/i18n/resources/ru.ts | 24 +++
packages/app/src/i18n/resources/zh-CN.ts | 24 +++
.../app/src/screens/settings/host-page.tsx | 192 ++++++++++++++++++
packages/client/src/daemon-client.ts | 26 +++
packages/protocol/src/messages.ts | 33 +++
packages/server/src/server/session.ts | 131 ++++++++++++
.../server/src/server/websocket-server.ts | 2 +
11 files changed, 528 insertions(+)
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 826a932942..2319a64f6f 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "منطقة الخطر",
remove: {
title: "إزالة المضيف",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index bdaa7f86ce..18125001b7 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1753,6 +1753,30 @@ export const en = {
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "Danger zone",
remove: {
title: "Remove host",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index a8fbf7a646..c40f29c43e 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "Zona de peligro",
remove: {
title: "Eliminar host",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 5be82675c8..1217f752a8 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
dialogFailedMessage:
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "Zone dangereuse",
remove: {
title: "Supprimer l'hôte",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index e54dd030d9..79aa703065 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "Опасная зона",
remove: {
title: "Удалить хост",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 7cb3cb50b8..abb29848e7 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
dialogFailedMessage: "无法打开重启确认对话框。",
},
+ update: {
+ title: "Update daemon",
+ hint: "Update the daemon to the latest version and restart it",
+ confirm: "Update",
+ confirmTitle: "Update {{name}}",
+ confirmMessage:
+ "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
+ updating: "Updating...",
+ phaseStarting: "Preparing update...",
+ phaseDownloading: "Downloading packages...",
+ phaseInstalling: "Installing...",
+ phaseComplete: "Update complete, restarting...",
+ unableToReconnectTitle: "Unable to reconnect",
+ unableToReconnectMessage:
+ "{{name}} did not come back online after updating. Please verify the daemon restarted.",
+ unavailableTitle: "Host unavailable",
+ unavailableMessage:
+ "This host is not connected. Wait for it to come online before updating.",
+ offlineTitle: "Host offline",
+ offlineMessage: "This host is offline. Wait until it is back online before updating.",
+ requestFailedTitle: "Update failed",
+ requestFailedMessage: "Failed to update the daemon: {{error}}",
+ dialogFailedMessage: "Unable to open the update confirmation dialog.",
+ },
dangerZone: "危险区域",
remove: {
title: "移除 Host",
diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx
index 7a976316b4..b16bb31fd5 100644
--- a/packages/app/src/screens/settings/host-page.tsx
+++ b/packages/app/src/screens/settings/host-page.tsx
@@ -1,6 +1,7 @@
import {
ArrowDown,
ArrowUp,
+ ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
+import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
+import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
@@ -353,6 +356,8 @@ export function HostSettingsPage({
{isLocalDaemon ? : null}
+ {!isLocalDaemon ? : null}
+
);
@@ -709,6 +714,193 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
);
}
+function UpdateDaemonCard({ host }: { host: HostProfile }) {
+ const { t } = useTranslation();
+ const { theme } = useUnistyles();
+ const daemonClient = useHostRuntimeClient(host.serverId);
+ const isConnected = useHostRuntimeIsConnected(host.serverId);
+ const runtime = getHostRuntimeStore();
+ const [isUpdating, setIsUpdating] = useState(false);
+ const [progressPhase, setProgressPhase] = useState(null);
+ const isMountedRef = useRef(true);
+
+ const daemonVersion = useSessionStore(
+ (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
+ );
+ const supportsSelfUpdate = useSessionStore(
+ (state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
+ );
+
+ const appVersion = resolveAppVersion();
+ const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const isHostConnected = useCallback(
+ () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
+ [host.serverId, runtime],
+ );
+
+ const waitForCondition = useCallback(
+ async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (!isMountedRef.current) return false;
+ if (predicate()) return true;
+ await delay(intervalMs);
+ }
+ return predicate();
+ },
+ [],
+ );
+
+ const waitForDaemonRestart = useCallback(async () => {
+ const disconnectTimeoutMs = 15000;
+ const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
+ if (isHostConnected()) {
+ await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
+ }
+ const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
+ if (isMountedRef.current) {
+ setIsUpdating(false);
+ setProgressPhase(null);
+ if (!reconnected) {
+ Alert.alert(
+ t("settings.host.daemon.update.unableToReconnectTitle"),
+ t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
+ );
+ }
+ }
+ }, [host.label, isHostConnected, t, waitForCondition]);
+
+ const handleUpdate = useCallback(() => {
+ if (!daemonClient) {
+ Alert.alert(
+ t("settings.host.daemon.update.unavailableTitle"),
+ t("settings.host.daemon.update.unavailableMessage"),
+ );
+ return;
+ }
+ if (!isHostConnected()) {
+ Alert.alert(
+ t("settings.host.daemon.update.offlineTitle"),
+ t("settings.host.daemon.update.offlineMessage"),
+ );
+ return;
+ }
+
+ void confirmDialog({
+ title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
+ message: t("settings.host.daemon.update.confirmMessage"),
+ confirmLabel: t("settings.host.daemon.update.confirm"),
+ cancelLabel: t("common.actions.cancel"),
+ destructive: false,
+ })
+ .then((confirmed) => {
+ if (!confirmed) return;
+ setIsUpdating(true);
+ setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
+
+ // Subscribe to progress events
+ const unsubscribe = daemonClient.subscribe((event) => {
+ if (event.type !== "status") return;
+ if (event.payload.status !== "daemon_update_progress") return;
+ if (!isMountedRef.current) return;
+ const phase = (event.payload as { phase?: string }).phase;
+ if (phase === "starting")
+ setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
+ else if (phase === "downloading")
+ setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
+ else if (phase === "installing")
+ setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
+ else if (phase === "complete")
+ setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
+ });
+
+ void daemonClient
+ .updateDaemon(`settings_daemon_update_${host.serverId}`)
+ .then((response) => {
+ unsubscribe();
+ if (!response.success) {
+ if (!isMountedRef.current) return undefined;
+ setIsUpdating(false);
+ setProgressPhase(null);
+ Alert.alert(
+ t("settings.host.daemon.update.requestFailedTitle"),
+ t("settings.host.daemon.update.requestFailedMessage", {
+ error: response.error ?? "Unknown error",
+ }),
+ );
+ return undefined;
+ }
+ // Update succeeded — wait for daemon to restart and reconnect
+ void waitForDaemonRestart();
+ return undefined;
+ })
+ .catch((error) => {
+ unsubscribe();
+ console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
+ if (!isMountedRef.current) return;
+ setIsUpdating(false);
+ setProgressPhase(null);
+ Alert.alert(
+ t("settings.host.daemon.update.requestFailedTitle"),
+ t("settings.host.daemon.update.requestFailedMessage", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ }),
+ );
+ });
+ return;
+ })
+ .catch((error) => {
+ console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
+ Alert.alert(
+ t("settings.host.daemon.update.requestFailedTitle"),
+ t("settings.host.daemon.update.dialogFailedMessage"),
+ );
+ });
+ }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
+
+ const updateIcon = useMemo(
+ () => ,
+ [theme.iconSize.sm, theme.colors.foreground],
+ );
+
+ // Don't show if the daemon doesn't support self-update or versions match
+ if (!supportsSelfUpdate || !hasVersionMismatch) {
+ return null;
+ }
+
+ const buttonLabel = isUpdating
+ ? (progressPhase ?? t("settings.host.daemon.update.updating"))
+ : t("settings.host.daemon.update.confirm");
+
+ return (
+
+
+
+ {t("settings.host.daemon.update.title")}
+ {t("settings.host.daemon.update.hint")}
+
+
+
+
+ );
+}
+
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts
index f072d6a107..8857b0418d 100644
--- a/packages/client/src/daemon-client.ts
+++ b/packages/client/src/daemon-client.ts
@@ -10,6 +10,7 @@ import {
RenameTerminalResponseSchema,
RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
+ DaemonUpdateResponseSchema,
SessionInboundMessageSchema,
type ServerInfoStatusPayload,
WSOutboundMessageSchema,
@@ -468,6 +469,7 @@ export interface FetchAgentTimelineOptions {
type AgentRefreshedStatusPayload = z.infer;
type RestartRequestedStatusPayload = z.infer;
type ShutdownRequestedStatusPayload = z.infer;
+type DaemonUpdateResponse = z.infer;
type FetchAgentsPayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
@@ -2636,6 +2638,30 @@ export class DaemonClient {
});
}
+ async updateDaemon(requestId?: string): Promise {
+ const resolvedRequestId = this.createRequestId(requestId);
+ const message = SessionInboundMessageSchema.parse({
+ type: "daemon.update.request",
+ requestId: resolvedRequestId,
+ });
+ return this.sendRequest({
+ requestId: resolvedRequestId,
+ message,
+ timeout: 300_000, // 5 minutes — npm update can be slow on remote machines
+ options: { skipQueue: true },
+ select: (msg) => {
+ const parsed = DaemonUpdateResponseSchema.safeParse(msg);
+ if (!parsed.success) {
+ return null;
+ }
+ if (parsed.data.payload.requestId !== resolvedRequestId) {
+ return null;
+ }
+ return parsed.data.payload;
+ },
+ });
+ }
+
// ============================================================================
// Audio / Voice
// ============================================================================
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index db2f9d65ba..99e3b55651 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -1252,6 +1252,11 @@ export const ShutdownServerRequestMessageSchema = z.object({
requestId: z.string(),
});
+export const DaemonUpdateRequestMessageSchema = z.object({
+ type: z.literal("daemon.update.request"),
+ requestId: z.string(),
+});
+
export const AgentTimelineCursorSchema = z.object({
epoch: z.string(),
seq: z.number().int().nonnegative(),
@@ -2053,6 +2058,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentRequestMessageSchema,
ShutdownServerRequestMessageSchema,
RestartServerRequestMessageSchema,
+ DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema,
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
@@ -2327,6 +2333,8 @@ export const ServerInfoStatusPayloadSchema = z
agentDetach: z.boolean().optional(),
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: z.boolean().optional(),
+ // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
+ daemonSelfUpdate: z.boolean().optional(),
})
.optional(),
})
@@ -2415,6 +2423,16 @@ export const ShutdownRequestedStatusPayloadSchema = z.object({
requestId: z.string(),
});
+export const DaemonUpdateProgressStatusPayloadSchema = z.object({
+ status: z.literal("daemon_update_progress"),
+ requestId: z.string(),
+ phase: z.enum(["starting", "downloading", "installing", "complete"]),
+});
+
+export type DaemonUpdateProgressStatusPayload = z.infer<
+ typeof DaemonUpdateProgressStatusPayloadSchema
+>;
+
export const DaemonConfigChangedStatusPayloadSchema = z
.object({
status: z.literal("daemon_config_changed"),
@@ -2429,6 +2447,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
AgentRefreshedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema,
+ DaemonUpdateProgressStatusPayloadSchema,
DaemonConfigChangedStatusPayloadSchema,
]);
@@ -4093,6 +4112,19 @@ export const TerminalAttentionRequiredSchema = z.object({
}),
});
+export const DaemonUpdateResponseSchema = z.object({
+ type: z.literal("daemon.update.response"),
+ payload: z.object({
+ requestId: z.string(),
+ success: z.boolean(),
+ error: z.string().nullable(),
+ previousVersion: z.string().nullable(),
+ newVersion: z.string().nullable(),
+ }),
+});
+
+export type DaemonUpdateResponse = z.infer;
+
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -4228,6 +4260,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
+ DaemonUpdateResponseSchema,
]);
export type SessionOutboundMessage = z.infer;
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 5b495a5489..359a75f26a 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -1,6 +1,7 @@
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { stat } from "node:fs/promises";
+import { spawn } from "node:child_process";
import { basename, normalize, resolve, sep } from "path";
import { homedir } from "node:os";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
@@ -585,6 +586,7 @@ export class Session {
private readonly daemonSession: DaemonSession;
private readonly workspaceScripts: WorkspaceScriptsService;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
+ private daemonUpdateInProgress = false;
constructor(options: SessionOptions) {
const {
@@ -1510,6 +1512,8 @@ export class Session {
return this.daemonSession.handleGetPairingOfferRequest(msg);
case "diagnostics.request":
return this.daemonSession.handleDiagnosticsRequest(msg);
+ case "daemon.update.request":
+ return this.handleDaemonUpdateRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
@@ -1777,6 +1781,133 @@ export class Session {
});
}
+ private async handleDaemonUpdateRequest(
+ msg: Extract,
+ ): Promise {
+ if (this.daemonUpdateInProgress) {
+ this.emit({
+ type: "rpc_error",
+ payload: {
+ requestId: msg.requestId,
+ requestType: "daemon.update.request",
+ error: "An update is already in progress",
+ code: "already_updating",
+ },
+ });
+ return;
+ }
+
+ this.daemonUpdateInProgress = true;
+ const previousVersion = this.daemonVersion ?? null;
+
+ const emitProgress = (phase: "starting" | "downloading" | "installing" | "complete") => {
+ this.emit({
+ type: "status",
+ payload: {
+ status: "daemon_update_progress",
+ requestId: msg.requestId,
+ phase,
+ },
+ });
+ };
+
+ const emitResponse = (success: boolean, error: string | null, newVersion: string | null) => {
+ this.emit({
+ type: "daemon.update.response",
+ payload: {
+ requestId: msg.requestId,
+ success,
+ error,
+ previousVersion,
+ newVersion,
+ },
+ });
+ };
+
+ try {
+ emitProgress("starting");
+
+ // Check if npm is available
+ const npmVersionOk = await new Promise((fulfill) => {
+ const proc = spawn("npm", ["--version"], {
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 10000,
+ });
+ proc.on("close", (code: number | null) => fulfill(code === 0));
+ proc.on("error", () => fulfill(false));
+ });
+
+ if (!npmVersionOk) {
+ emitResponse(false, "npm is not available on this system", null);
+ this.daemonUpdateInProgress = false;
+ return;
+ }
+
+ emitProgress("downloading");
+
+ // Run npm update -g @getpaseo/cli
+ const result = await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
+ (fulfill) => {
+ const proc = spawn("npm", ["update", "-g", "@getpaseo/cli"], {
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 300_000, // 5 minutes
+ });
+ let stdout = "";
+ let stderr = "";
+ proc.stdout.on("data", (data: Buffer) => {
+ stdout += data.toString();
+ });
+ proc.stderr.on("data", (data: Buffer) => {
+ stderr += data.toString();
+ });
+ proc.on("close", (code: number | null) => {
+ fulfill({ exitCode: code ?? 1, stdout, stderr });
+ });
+ proc.on("error", (err: Error) => {
+ fulfill({ exitCode: 1, stdout, stderr: err.message });
+ });
+ },
+ );
+
+ if (result.exitCode !== 0) {
+ const errorMsg =
+ result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
+ this.sessionLogger.error(
+ { exitCode: result.exitCode, stderr: result.stderr },
+ "Daemon update failed",
+ );
+ emitResponse(false, errorMsg, null);
+ this.daemonUpdateInProgress = false;
+ return;
+ }
+
+ emitProgress("installing");
+
+ // Try to parse the new version from npm output
+ let newVersion: string | null = null;
+ const versionMatch = result.stdout.match(/@getpaseo\/cli@(\S+)/);
+ if (versionMatch) {
+ newVersion = versionMatch[1];
+ }
+
+ emitProgress("complete");
+ emitResponse(true, null, newVersion);
+
+ // Trigger restart to load the new code
+ this.emitLifecycleIntent({
+ type: "restart",
+ clientId: this.clientId,
+ requestId: msg.requestId,
+ reason: "daemon_update",
+ });
+ } catch (error) {
+ this.sessionLogger.error({ err: error }, "Daemon update failed with exception");
+ emitResponse(false, error instanceof Error ? error.message : "Unknown error", null);
+ } finally {
+ this.daemonUpdateInProgress = false;
+ }
+ }
+
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
if (!this.onLifecycleIntent) {
return;
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index 01e0b260ff..c2eb412b23 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -1183,6 +1183,8 @@ export class VoiceAssistantWebSocketServer {
agentDetach: true,
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: true,
+ // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
+ daemonSelfUpdate: true,
},
};
}