diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 826a93294..2319a64f6 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 bdaa7f86c..18125001b 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 a8fbf7a64..c40f29c43 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 5be82675c..1217f752a 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/ja.ts b/packages/app/src/i18n/resources/ja.ts
index f6f7cbf42..7c2f520e3 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -1765,6 +1765,31 @@ export const ja: TranslationResources = {
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
+ update: {
+ title: "デーモンを更新",
+ hint: "デーモンを最新バージョンに更新して再起動します",
+ confirm: "更新",
+ confirmTitle: "{{name}}を更新",
+ confirmMessage:
+ "これによりデーモンが最新バージョンに更新され、再起動されます。実行中のエージェントは一時的に中断されます。",
+ updating: "更新中...",
+ phaseStarting: "更新を準備中...",
+ phaseDownloading: "パッケージをダウンロード中...",
+ phaseInstalling: "インストール中...",
+ phaseComplete: "更新が完了しました。再起動中...",
+ unableToReconnectTitle: "再接続できません",
+ unableToReconnectMessage:
+ "{{name}}が更新後にオンラインに戻りませんでした。デーモンが再起動されたことを確認してください。",
+ unavailableTitle: "ホストが利用できません",
+ unavailableMessage:
+ "このホストは接続されていません。更新する前にオンラインになるまでお待ちください。",
+ offlineTitle: "ホストオフライン",
+ offlineMessage:
+ "このホストはオフラインです。オンラインに戻るまで待ってから更新してください。",
+ requestFailedTitle: "更新に失敗しました",
+ requestFailedMessage: "デーモンの更新に失敗しました: {{error}}",
+ dialogFailedMessage: "更新確認ダイアログを開けませんでした。",
+ },
dangerZone: "危険ゾーン",
remove: {
title: "ホストを削除",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index e00c314f1..a4613aecb 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -1771,6 +1771,31 @@ export const ptBR: TranslationResources = {
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
},
+ update: {
+ title: "Atualizar daemon",
+ hint: "Atualiza o daemon para a versão mais recente e o reinicia",
+ confirm: "Atualizar",
+ confirmTitle: "Atualizar {{name}}",
+ confirmMessage:
+ "Isso vai atualizar o daemon para a versão mais recente e reiniciá-lo. Agentes em execução serão interrompidos brevemente.",
+ updating: "Atualizando...",
+ phaseStarting: "Preparando atualização...",
+ phaseDownloading: "Baixando pacotes...",
+ phaseInstalling: "Instalando...",
+ phaseComplete: "Atualização concluída, reiniciando...",
+ unableToReconnectTitle: "Não foi possível reconectar",
+ unableToReconnectMessage:
+ "{{name}} não voltou a ficar online após a atualização. Verifique se o daemon reiniciou.",
+ unavailableTitle: "Host indisponível",
+ unavailableMessage:
+ "Este host não está conectado. Aguarde até ele ficar online antes de atualizar.",
+ offlineTitle: "Host offline",
+ offlineMessage:
+ "Este host está offline. Aguarde até ele voltar a ficar online antes de atualizar.",
+ requestFailedTitle: "Falha na atualização",
+ requestFailedMessage: "Falha ao atualizar o daemon: {{error}}",
+ dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação da atualização.",
+ },
dangerZone: "Zona de perigo",
remove: {
title: "Remover host",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index e54dd030d..79aa70306 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 7cb3cb50b..abb29848e 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 7a976316b..8f39fd0be 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,214 @@ 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 unsubscribeRef = useRef<(() => void) | null>(null);
+
+ 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;
+ unsubscribeRef.current?.();
+ };
+ }, []);
+
+ const isHostConnected = useCallback(
+ () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
+ [host.serverId, runtime],
+ );
+ const hasReconnectedAfter = useCallback(
+ (startGeneration: number | null) => {
+ const snapshot = runtime.getSnapshot(host.serverId);
+ if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
+ return startGeneration === null || snapshot.clientGeneration !== startGeneration;
+ },
+ [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 (startGeneration: number | null) => {
+ const disconnectTimeoutMs = 15000;
+ const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
+ if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
+ await waitForCondition(
+ () => !isHostConnected() || hasReconnectedAfter(startGeneration),
+ disconnectTimeoutMs,
+ );
+ }
+ const reconnected =
+ hasReconnectedAfter(startGeneration) ||
+ (await waitForCondition(() => hasReconnectedAfter(startGeneration), 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 }),
+ );
+ }
+ }
+ },
+ [hasReconnectedAfter, 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;
+ const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
+ setIsUpdating(true);
+ setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
+ const requestId = `settings_daemon_update_${host.serverId}`;
+
+ const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
+ if (message.payload.requestId !== requestId) return;
+ if (!isMountedRef.current) return;
+ const { phase } = message.payload;
+ 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"));
+ });
+ unsubscribeRef.current = unsubscribe;
+
+ void daemonClient
+ .updateDaemon(requestId)
+ .then((response) => {
+ unsubscribeRef.current = null;
+ 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(startGeneration);
+ return undefined;
+ })
+ .catch((error) => {
+ unsubscribeRef.current = null;
+ 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, runtime, 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 f072d6a10..8857b0418 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.test.ts b/packages/protocol/src/messages.test.ts
index f2247823f..9cd91e40d 100644
--- a/packages/protocol/src/messages.test.ts
+++ b/packages/protocol/src/messages.test.ts
@@ -402,3 +402,23 @@ describe("paseo worktree archive request compatibility", () => {
expect(parsed.scope).toBe("workspace");
});
});
+
+describe("daemon update messages", () => {
+ test("daemon update progress is a scoped outbound message", () => {
+ const parsed = SessionOutboundMessageSchema.parse({
+ type: "daemon.update.progress",
+ payload: {
+ requestId: "update-1",
+ phase: "installing",
+ },
+ });
+
+ expect(parsed).toEqual({
+ type: "daemon.update.progress",
+ payload: {
+ requestId: "update-1",
+ phase: "installing",
+ },
+ });
+ });
+});
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index db2f9d65b..499de202a 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(),
})
@@ -4093,6 +4101,29 @@ 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 DaemonUpdateProgressMessageSchema = z.object({
+ type: z.literal("daemon.update.progress"),
+ payload: z.object({
+ requestId: z.string(),
+ phase: z.enum(["starting", "downloading", "installing", "complete"]),
+ }),
+});
+
+export type DaemonUpdateProgressMessage = z.infer;
+
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -4228,6 +4259,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
+ DaemonUpdateProgressMessageSchema,
+ DaemonUpdateResponseSchema,
]);
export type SessionOutboundMessage = z.infer;
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 5b495a548..75999482d 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -777,7 +777,9 @@ export class Session {
this.daemonSession = new DaemonSession({
host: {
emit: (msg) => this.emit(msg),
+ emitLifecycleIntent: (intent) => this.emitLifecycleIntent(intent),
},
+ clientId: this.clientId,
paseoHome: this.paseoHome,
serverId,
daemonVersion,
@@ -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.daemonSession.handleUpdateRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts
new file mode 100644
index 000000000..d6c57f14c
--- /dev/null
+++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, test } from "vitest";
+import { createTestLogger } from "../../../test-utils/test-logger.js";
+import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
+import {
+ DaemonSelfUpdateInProgressError,
+ type DaemonSelfUpdateInput,
+ type DaemonSelfUpdater,
+} from "./daemon-self-updater.js";
+import {
+ DaemonSelfUpdateSessionController,
+ type DaemonSelfUpdateSessionControllerOptions,
+} from "./daemon-self-update-session-controller.js";
+
+type TestUpdater = Pick;
+type RestartIntent = Parameters[0];
+
+interface ControllerHarness {
+ controller: DaemonSelfUpdateSessionController;
+ emitted: SessionOutboundMessage[];
+ restartIntents: RestartIntent[];
+}
+
+function createController(input: {
+ updater: TestUpdater;
+ daemonVersion?: string | null;
+}): ControllerHarness {
+ const emitted: SessionOutboundMessage[] = [];
+ const restartIntents: RestartIntent[] = [];
+ const controller = new DaemonSelfUpdateSessionController({
+ clientId: "client-1",
+ daemonVersion: input.daemonVersion ?? "0.1.15",
+ emit: (msg) => {
+ emitted.push(msg);
+ },
+ emitLifecycleIntent: (intent) => {
+ restartIntents.push(intent);
+ },
+ sessionLogger: createTestLogger(),
+ updater: input.updater,
+ });
+
+ return { controller, emitted, restartIntents };
+}
+
+const updateRequest: SessionInboundMessage = {
+ type: "daemon.update.request",
+ requestId: "update-1",
+};
+
+describe("DaemonSelfUpdateSessionController", () => {
+ test("returns undefined synchronously for messages owned by another subsystem", () => {
+ const updater: TestUpdater = {
+ async update() {
+ throw new Error("update should not run");
+ },
+ };
+ const { controller, emitted, restartIntents } = createController({ updater });
+ const message: SessionInboundMessage = {
+ type: "daemon.get_status.request",
+ requestId: "status-1",
+ };
+
+ const result = controller.dispatch(message);
+
+ expect(result).toBeUndefined();
+ expect(emitted).toEqual([]);
+ expect(restartIntents).toEqual([]);
+ });
+
+ test("emits progress, response, and restart lifecycle intent after a successful update", async () => {
+ let updateInput: DaemonSelfUpdateInput | null = null;
+ const updater: TestUpdater = {
+ async update(input) {
+ updateInput = input;
+ input.onProgress("starting");
+ input.onProgress("installing");
+ return { success: true, error: null, newVersion: "0.1.96" };
+ },
+ };
+ const { controller, emitted, restartIntents } = createController({ updater });
+
+ await controller.dispatch(updateRequest);
+
+ expect(updateInput?.daemonVersion).toBe("0.1.15");
+ expect(emitted).toEqual([
+ {
+ type: "daemon.update.progress",
+ payload: {
+ requestId: "update-1",
+ phase: "starting",
+ },
+ },
+ {
+ type: "daemon.update.progress",
+ payload: {
+ requestId: "update-1",
+ phase: "installing",
+ },
+ },
+ {
+ type: "daemon.update.response",
+ payload: {
+ requestId: "update-1",
+ success: true,
+ error: null,
+ previousVersion: "0.1.15",
+ newVersion: "0.1.96",
+ },
+ },
+ ]);
+ expect(restartIntents).toEqual([
+ {
+ type: "restart",
+ clientId: "client-1",
+ requestId: "update-1",
+ reason: "daemon_update",
+ },
+ ]);
+ });
+
+ test("emits a failed response without restart lifecycle intent", async () => {
+ const updater: TestUpdater = {
+ async update() {
+ return { success: false, error: "not an npm global install", newVersion: null };
+ },
+ };
+ const { controller, emitted, restartIntents } = createController({ updater });
+
+ await controller.dispatch(updateRequest);
+
+ expect(emitted).toEqual([
+ {
+ type: "daemon.update.response",
+ payload: {
+ requestId: "update-1",
+ success: false,
+ error: "not an npm global install",
+ previousVersion: "0.1.15",
+ newVersion: null,
+ },
+ },
+ ]);
+ expect(restartIntents).toEqual([]);
+ });
+
+ test("maps concurrent updates to rpc_error", async () => {
+ const updater: TestUpdater = {
+ async update() {
+ throw new DaemonSelfUpdateInProgressError();
+ },
+ };
+ const { controller, emitted, restartIntents } = createController({ updater });
+
+ await controller.dispatch(updateRequest);
+
+ expect(emitted).toEqual([
+ {
+ type: "rpc_error",
+ payload: {
+ requestId: "update-1",
+ requestType: "daemon.update.request",
+ error: "An update is already in progress",
+ code: "already_updating",
+ },
+ },
+ ]);
+ expect(restartIntents).toEqual([]);
+ });
+});
diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts
new file mode 100644
index 000000000..9f7975e1c
--- /dev/null
+++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts
@@ -0,0 +1,134 @@
+import type pino from "pino";
+import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
+import {
+ daemonSelfUpdater,
+ DaemonSelfUpdateInProgressError,
+ type DaemonSelfUpdatePhase,
+ type DaemonSelfUpdater,
+} from "./daemon-self-updater.js";
+import { getErrorMessage } from "@getpaseo/protocol/error-utils";
+
+type DaemonUpdateRequest = Extract;
+
+const DAEMON_SELF_UPDATE_MESSAGE_TYPES: ReadonlySet = new Set([
+ "daemon.update.request",
+]);
+
+interface DaemonSelfUpdateRestartIntent {
+ type: "restart";
+ clientId: string;
+ requestId: string;
+ reason?: string;
+}
+
+export interface DaemonSelfUpdateSessionControllerOptions {
+ clientId: string;
+ daemonVersion: string | null;
+ emit: (msg: SessionOutboundMessage) => void;
+ emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
+ sessionLogger: pino.Logger;
+ updater?: Pick;
+}
+
+function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpdateRequest {
+ return DAEMON_SELF_UPDATE_MESSAGE_TYPES.has(msg.type);
+}
+
+export class DaemonSelfUpdateSessionController {
+ private readonly clientId: string;
+ private readonly daemonVersion: string | null;
+ private readonly emit: (msg: SessionOutboundMessage) => void;
+ private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
+ private readonly sessionLogger: pino.Logger;
+ private readonly updater: Pick;
+
+ constructor(options: DaemonSelfUpdateSessionControllerOptions) {
+ this.clientId = options.clientId;
+ this.daemonVersion = options.daemonVersion;
+ this.emit = options.emit;
+ this.emitLifecycleIntent = options.emitLifecycleIntent;
+ this.sessionLogger = options.sessionLogger;
+ this.updater = options.updater ?? daemonSelfUpdater;
+ }
+
+ dispatch(msg: SessionInboundMessage): Promise | undefined {
+ if (!isDaemonSelfUpdateMessage(msg)) {
+ return undefined;
+ }
+ return this.handleDaemonUpdateRequest(msg);
+ }
+
+ private async handleDaemonUpdateRequest(msg: DaemonUpdateRequest): Promise {
+ const previousVersion = this.daemonVersion;
+
+ try {
+ const result = await this.updater.update({
+ daemonVersion: previousVersion,
+ onProgress: (phase) => this.emitProgress(msg.requestId, phase),
+ logger: this.sessionLogger,
+ });
+
+ this.emitResponse({
+ requestId: msg.requestId,
+ success: result.success,
+ error: result.error,
+ previousVersion,
+ newVersion: result.newVersion,
+ });
+ if (!result.success) {
+ return;
+ }
+
+ this.emitLifecycleIntent({
+ type: "restart",
+ clientId: this.clientId,
+ requestId: msg.requestId,
+ reason: "daemon_update",
+ });
+ } catch (error) {
+ if (error instanceof DaemonSelfUpdateInProgressError) {
+ this.emit({
+ type: "rpc_error",
+ payload: {
+ requestId: msg.requestId,
+ requestType: "daemon.update.request",
+ error: error.message,
+ code: "already_updating",
+ },
+ });
+ return;
+ }
+ this.sessionLogger.error({ err: error }, "Daemon update failed with exception");
+ this.emitResponse({
+ requestId: msg.requestId,
+ success: false,
+ error: getErrorMessage(error),
+ previousVersion,
+ newVersion: null,
+ });
+ }
+ }
+
+ private emitProgress(requestId: string, phase: DaemonSelfUpdatePhase): void {
+ this.emit({
+ type: "daemon.update.progress",
+ payload: {
+ requestId,
+ phase,
+ },
+ });
+ }
+
+ private emitResponse(payload: {
+ requestId: string;
+ success: boolean;
+ error: string | null;
+ previousVersion: string | null;
+ newVersion: string | null;
+ }): void {
+ this.emit({
+ type: "daemon.update.response",
+ payload,
+ });
+ }
+}
diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts
new file mode 100644
index 000000000..2736e3834
--- /dev/null
+++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, test } from "vitest";
+import {
+ DaemonSelfUpdateInProgressError,
+ DaemonSelfUpdater,
+ type DaemonSelfUpdateRuntime,
+ type DaemonSelfUpdatePhase,
+} from "./daemon-self-updater.js";
+import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js";
+
+interface TestLogger {
+ errors: Array<{ obj: object; msg?: string }>;
+ warnings: Array<{ obj: object; msg?: string }>;
+ error(obj: object, msg?: string): void;
+ warn(obj: object, msg?: string): void;
+}
+
+type Inspection = NpmGlobalPaseoInstall | Error;
+type RuntimeCall = "inspect" | "installLatest";
+
+const globalRoot = "/global/lib";
+const globalNodeModules = `${globalRoot}/node_modules`;
+const cliPackagePath = `${globalNodeModules}/@getpaseo/cli`;
+const npmServerPackageRoot = `${cliPackagePath}/node_modules/@getpaseo/server`;
+const sourceServerPackageRoot = "/repo/packages/server";
+
+function npmGlobalPaseoInstall(
+ version: string,
+ options?: { linked?: boolean },
+): NpmGlobalPaseoInstall {
+ return {
+ version,
+ packagePath: cliPackagePath,
+ globalRootPath: globalRoot,
+ isLinked: options?.linked === true,
+ };
+}
+
+function createLogger(): TestLogger {
+ return {
+ errors: [],
+ warnings: [],
+ error(obj, msg) {
+ this.errors.push({ obj, msg });
+ },
+ warn(obj, msg) {
+ this.warnings.push({ obj, msg });
+ },
+ };
+}
+
+function createRuntime(input: {
+ inspections: Inspection[];
+ currentServerPackageRoot?: string | null;
+ installResult?: CommandResult;
+ calls?: RuntimeCall[];
+}): DaemonSelfUpdateRuntime {
+ const calls = input.calls ?? [];
+ return {
+ npm: {
+ async inspect() {
+ calls.push("inspect");
+ const inspection = input.inspections.shift();
+ if (!inspection) {
+ throw new Error("Unexpected npm global install inspection");
+ }
+ if (inspection instanceof Error) {
+ throw inspection;
+ }
+ return inspection;
+ },
+ async installLatest() {
+ calls.push("installLatest");
+ return input.installResult ?? { exitCode: 0, stdout: "changed 42 packages", stderr: "" };
+ },
+ },
+ installOrigin: {
+ resolveCurrentServerPackageRoot() {
+ return input.currentServerPackageRoot ?? npmServerPackageRoot;
+ },
+ },
+ };
+}
+
+async function runUpdate(input: {
+ runtime: DaemonSelfUpdateRuntime;
+ daemonVersion?: string | null;
+ phases?: DaemonSelfUpdatePhase[];
+}) {
+ const logger = createLogger();
+ const updater = new DaemonSelfUpdater(input.runtime);
+ const phases = input.phases ?? [];
+ const result = await updater.update({
+ daemonVersion: input.daemonVersion ?? "0.1.15",
+ onProgress: (phase) => phases.push(phase),
+ logger,
+ });
+ return { result, logger, phases };
+}
+
+describe("DaemonSelfUpdater", () => {
+ test("updates a daemon that is running from the npm global cli install", async () => {
+ const calls: RuntimeCall[] = [];
+ const runtime = createRuntime({
+ calls,
+ inspections: [npmGlobalPaseoInstall("0.1.15"), npmGlobalPaseoInstall("0.1.96")],
+ });
+
+ const { result, phases } = await runUpdate({ runtime });
+
+ expect(result).toEqual({
+ success: true,
+ error: null,
+ newVersion: "0.1.96",
+ });
+ expect(phases).toEqual(["starting", "downloading", "installing", "complete"]);
+ expect(calls).toEqual(["inspect", "installLatest", "inspect"]);
+ });
+
+ test("does not run install when npm global cli is missing", async () => {
+ const calls: RuntimeCall[] = [];
+ const runtime = createRuntime({
+ calls,
+ inspections: [new Error("@getpaseo/cli is not installed with npm -g on this host")],
+ });
+
+ const { result, phases } = await runUpdate({ runtime });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("@getpaseo/cli is not installed with npm -g on this host");
+ expect(phases).toEqual(["starting"]);
+ expect(calls).toEqual(["inspect"]);
+ });
+
+ test("does not update a daemon whose version does not match the npm global cli", async () => {
+ const calls: RuntimeCall[] = [];
+ const runtime = createRuntime({
+ calls,
+ inspections: [npmGlobalPaseoInstall("0.1.15")],
+ });
+
+ const { result } = await runUpdate({ runtime, daemonVersion: "0.1.96" });
+
+ expect(result).toEqual({
+ success: false,
+ error:
+ "This daemon is not running from the npm global @getpaseo/cli install (global npm has 0.1.15, daemon is 0.1.96).",
+ newVersion: null,
+ });
+ expect(calls).toEqual(["inspect"]);
+ });
+
+ test("does not update a daemon running outside the npm global package tree", async () => {
+ const calls: RuntimeCall[] = [];
+ const runtime = createRuntime({
+ calls,
+ currentServerPackageRoot: sourceServerPackageRoot,
+ inspections: [npmGlobalPaseoInstall("0.1.15")],
+ });
+
+ const { result } = await runUpdate({ runtime });
+
+ expect(result).toEqual({
+ success: false,
+ error: "This daemon is not running from the npm global @getpaseo/cli install.",
+ newVersion: null,
+ });
+ expect(calls).toEqual(["inspect"]);
+ });
+
+ test("does not update linked global installs", async () => {
+ const runtime = createRuntime({
+ inspections: [npmGlobalPaseoInstall("0.1.15", { linked: true })],
+ });
+
+ const { result } = await runUpdate({ runtime });
+
+ expect(result).toEqual({
+ success: false,
+ error:
+ "The global @getpaseo/cli install is linked; self-update only supports normal npm global installs.",
+ newVersion: null,
+ });
+ });
+
+ test("rejects concurrent update requests", async () => {
+ const calls: RuntimeCall[] = [];
+ let resolveInstall: ((result: CommandResult) => void) | null = null;
+ let installStartedResolve: (() => void) | null = null;
+ const installStarted = new Promise((resolve) => {
+ installStartedResolve = resolve;
+ });
+ const runtime: DaemonSelfUpdateRuntime = {
+ npm: {
+ async inspect() {
+ calls.push("inspect");
+ return npmGlobalPaseoInstall("0.1.15");
+ },
+ async installLatest() {
+ calls.push("installLatest");
+ installStartedResolve?.();
+ return new Promise((resolve) => {
+ resolveInstall = resolve;
+ });
+ },
+ },
+ installOrigin: {
+ resolveCurrentServerPackageRoot() {
+ return npmServerPackageRoot;
+ },
+ },
+ };
+ const logger = createLogger();
+ const updater = new DaemonSelfUpdater(runtime);
+
+ const firstUpdate = updater.update({
+ daemonVersion: "0.1.15",
+ onProgress: () => {},
+ logger,
+ });
+ await installStarted;
+
+ await expect(
+ updater.update({
+ daemonVersion: "0.1.15",
+ onProgress: () => {},
+ logger,
+ }),
+ ).rejects.toBeInstanceOf(DaemonSelfUpdateInProgressError);
+
+ resolveInstall?.({ exitCode: 0, stdout: "updated", stderr: "" });
+ await expect(firstUpdate).resolves.toMatchObject({ success: true });
+ expect(calls).toEqual(["inspect", "installLatest", "inspect"]);
+ });
+});
diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts
new file mode 100644
index 000000000..cdb958935
--- /dev/null
+++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts
@@ -0,0 +1,98 @@
+import { getErrorMessage } from "@getpaseo/protocol/error-utils";
+import {
+ daemonInstallOriginRuntime,
+ validateDaemonInstallOrigin,
+ type DaemonInstallOriginRuntime,
+} from "./install-origin.js";
+import { npmGlobalPaseoCli, type NpmGlobalPaseoCli } from "./npm-global-cli.js";
+
+export type DaemonSelfUpdatePhase = "starting" | "downloading" | "installing" | "complete";
+
+export interface DaemonSelfUpdateResult {
+ success: boolean;
+ error: string | null;
+ newVersion: string | null;
+}
+
+export interface DaemonSelfUpdateInput {
+ daemonVersion: string | null;
+ onProgress: (phase: DaemonSelfUpdatePhase) => void;
+ logger: DaemonSelfUpdateLogger;
+}
+
+export interface DaemonSelfUpdateLogger {
+ error(obj: object, msg?: string): void;
+ warn(obj: object, msg?: string): void;
+}
+
+export interface DaemonSelfUpdateRuntime {
+ npm: NpmGlobalPaseoCli;
+ installOrigin: DaemonInstallOriginRuntime;
+}
+
+export class DaemonSelfUpdateInProgressError extends Error {
+ constructor() {
+ super("An update is already in progress");
+ this.name = "DaemonSelfUpdateInProgressError";
+ }
+}
+
+const defaultRuntime: DaemonSelfUpdateRuntime = {
+ npm: npmGlobalPaseoCli,
+ installOrigin: daemonInstallOriginRuntime,
+};
+
+export class DaemonSelfUpdater {
+ private inProgress = false;
+
+ constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {}
+
+ async update(input: DaemonSelfUpdateInput): Promise {
+ if (this.inProgress) {
+ throw new DaemonSelfUpdateInProgressError();
+ }
+
+ this.inProgress = true;
+ try {
+ input.onProgress("starting");
+ const install = await this.runtime.npm.inspect();
+ const unsupportedReason = validateDaemonInstallOrigin(
+ install,
+ input.daemonVersion,
+ this.runtime.installOrigin,
+ );
+ if (unsupportedReason) {
+ return { success: false, error: unsupportedReason, newVersion: null };
+ }
+
+ input.onProgress("downloading");
+ input.onProgress("installing");
+
+ const result = await this.runtime.npm.installLatest();
+ if (result.exitCode !== 0) {
+ const error =
+ result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
+ input.logger.error(
+ { exitCode: result.exitCode, stderr: result.stderr },
+ "Daemon self-update failed",
+ );
+ return { success: false, error, newVersion: null };
+ }
+
+ const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => {
+ input.logger.warn({ err: error }, "Unable to read updated npm package version");
+ return null;
+ });
+
+ input.onProgress("complete");
+ return { success: true, error: null, newVersion: updatedInstall?.version ?? null };
+ } catch (error) {
+ input.logger.error({ err: error }, "Daemon self-update failed with exception");
+ return { success: false, error: getErrorMessage(error), newVersion: null };
+ } finally {
+ this.inProgress = false;
+ }
+ }
+}
+
+export const daemonSelfUpdater = new DaemonSelfUpdater();
diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts
index 856091d9e..22ca4c43d 100644
--- a/packages/server/src/server/session/daemon/daemon-session.test.ts
+++ b/packages/server/src/server/session/daemon/daemon-session.test.ts
@@ -42,10 +42,15 @@ function makeSubsystem(overrides: {
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
}) {
const emitted: SessionOutboundMessage[] = [];
- const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) };
+ const restartIntents: Parameters[0][] = [];
+ const host: DaemonSessionHost = {
+ emit: (msg) => emitted.push(msg),
+ emitLifecycleIntent: (intent) => restartIntents.push(intent),
+ };
const paseoHome = makeHome();
const subsystem = new DaemonSession({
host,
+ clientId: "client-1",
paseoHome,
serverId: overrides.serverId,
daemonVersion: overrides.daemonVersion,
@@ -57,7 +62,7 @@ function makeSubsystem(overrides: {
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
logger: pino({ level: "silent" }),
});
- return { subsystem, emitted, paseoHome };
+ return { subsystem, emitted, paseoHome, restartIntents };
}
describe("DaemonSession", () => {
diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts
index 0c6781d29..2669afdc0 100644
--- a/packages/server/src/server/session/daemon/daemon-session.ts
+++ b/packages/server/src/server/session/daemon/daemon-session.ts
@@ -7,6 +7,7 @@ import {
collectDaemonDiagnostics,
type DaemonWebSocketRuntimeDiagnosticSnapshot,
} from "./diagnostics.js";
+import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
@@ -24,10 +25,17 @@ export interface DaemonRuntimeConfig {
export interface DaemonSessionHost {
emit(msg: SessionOutboundMessage): void;
+ emitLifecycleIntent(intent: {
+ type: "restart";
+ clientId: string;
+ requestId: string;
+ reason?: string;
+ }): void;
}
export interface DaemonSessionOptions {
host: DaemonSessionHost;
+ clientId: string;
paseoHome: string;
serverId: string | undefined;
daemonVersion: string | undefined;
@@ -49,6 +57,7 @@ export interface DaemonSessionOptions {
*/
export class DaemonSession {
private readonly host: DaemonSessionHost;
+ private readonly clientId: string;
private readonly paseoHome: string;
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
@@ -59,9 +68,11 @@ export class DaemonSession {
private readonly listProviderAvailability: () => Promise;
private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
private readonly logger: pino.Logger;
+ private readonly selfUpdate: DaemonSelfUpdateSessionController;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
+ this.clientId = options.clientId;
this.paseoHome = options.paseoHome;
this.serverId = options.serverId;
this.daemonVersion = options.daemonVersion;
@@ -72,6 +83,13 @@ export class DaemonSession {
this.listProviderAvailability = options.listProviderAvailability;
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
this.logger = options.logger;
+ this.selfUpdate = new DaemonSelfUpdateSessionController({
+ clientId: this.clientId,
+ daemonVersion: this.daemonVersion ?? null,
+ emit: (msg) => this.host.emit(msg),
+ emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent),
+ sessionLogger: this.logger,
+ });
}
async handleGetStatusRequest(
@@ -191,4 +209,10 @@ export class DaemonSession {
});
}
}
+
+ async handleUpdateRequest(
+ msg: Extract,
+ ): Promise {
+ await this.selfUpdate.dispatch(msg);
+ }
}
diff --git a/packages/server/src/server/session/daemon/install-origin.ts b/packages/server/src/server/session/daemon/install-origin.ts
new file mode 100644
index 000000000..3f305ab3a
--- /dev/null
+++ b/packages/server/src/server/session/daemon/install-origin.ts
@@ -0,0 +1,89 @@
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import { isRealpathInsideRoot } from "../../../utils/path.js";
+import { PASEO_CLI_PACKAGE, type NpmGlobalPaseoInstall } from "./npm-global-cli.js";
+
+const PackageJsonSchema = z.object({ name: z.string().optional() }).passthrough();
+
+export interface DaemonInstallOriginRuntime {
+ resolveCurrentServerPackageRoot(): string | null;
+}
+
+export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = {
+ resolveCurrentServerPackageRoot,
+};
+
+export function validateDaemonInstallOrigin(
+ install: NpmGlobalPaseoInstall,
+ daemonVersion: string | null,
+ runtime: DaemonInstallOriginRuntime = daemonInstallOriginRuntime,
+): string | null {
+ if (install.isLinked) {
+ return `The global ${PASEO_CLI_PACKAGE} install is linked; self-update only supports normal npm global installs.`;
+ }
+
+ if (daemonVersion && install.version !== daemonVersion) {
+ return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install (global npm has ${install.version}, daemon is ${daemonVersion}).`;
+ }
+
+ const currentServerPackageRoot = runtime.resolveCurrentServerPackageRoot();
+ if (!currentServerPackageRoot) {
+ return "Unable to verify that this daemon is running from an npm global install.";
+ }
+
+ if (!isCurrentServerUnderNpmInstall(currentServerPackageRoot, install)) {
+ return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install.`;
+ }
+
+ return null;
+}
+
+function isCurrentServerUnderNpmInstall(
+ currentServerPackageRoot: string,
+ install: NpmGlobalPaseoInstall,
+): boolean {
+ const roots = install.globalRootPath
+ ? [install.packagePath, globalNodeModulesPath(install.globalRootPath)]
+ : [install.packagePath];
+
+ return roots.some((root) => isRealpathInsideRoot(root, currentServerPackageRoot));
+}
+
+function globalNodeModulesPath(globalRootPath: string): string {
+ const normalized = path.normalize(globalRootPath);
+ return path.basename(normalized) === "node_modules"
+ ? normalized
+ : path.join(normalized, "node_modules");
+}
+
+function resolveCurrentServerPackageRoot(): string | null {
+ return resolvePackageRootFrom(fileURLToPath(import.meta.url), "@getpaseo/server");
+}
+
+function resolvePackageRootFrom(startPath: string, packageName: string): string | null {
+ let currentDir = path.dirname(startPath);
+
+ while (true) {
+ const packageJsonPath = path.join(currentDir, "package.json");
+ if (existsSync(packageJsonPath)) {
+ try {
+ const packageJson = PackageJsonSchema.parse(
+ JSON.parse(readFileSync(packageJsonPath, "utf8")),
+ );
+ if (packageJson.name === packageName) {
+ return currentDir;
+ }
+ } catch {
+ return null;
+ }
+ }
+
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir) {
+ return null;
+ }
+ currentDir = parentDir;
+ }
+}
diff --git a/packages/server/src/server/session/daemon/npm-global-cli.test.ts b/packages/server/src/server/session/daemon/npm-global-cli.test.ts
new file mode 100644
index 000000000..aece7eb09
--- /dev/null
+++ b/packages/server/src/server/session/daemon/npm-global-cli.test.ts
@@ -0,0 +1,107 @@
+import path from "node:path";
+import { describe, expect, test } from "vitest";
+import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js";
+
+interface CommandCall {
+ command: string;
+ args: string[];
+ timeout?: number;
+ maxBuffer?: number;
+}
+
+const globalRoot = path.join(path.sep, "global", "lib");
+const globalNodeModules = path.join(globalRoot, "node_modules");
+const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli");
+
+function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string {
+ return JSON.stringify({
+ name: "lib",
+ path: globalRoot,
+ dependencies: {
+ "@getpaseo/cli": {
+ version,
+ path: cliPackagePath,
+ link: options?.linked === true,
+ },
+ },
+ });
+}
+
+describe("DefaultNpmGlobalPaseoCli", () => {
+ test("inspects the npm global cli install with npm -g ls", async () => {
+ const calls: CommandCall[] = [];
+ const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
+ calls.push({
+ command,
+ args,
+ timeout: options?.timeout,
+ maxBuffer: options?.maxBuffer,
+ });
+ return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" };
+ });
+
+ await expect(cli.inspect()).resolves.toEqual({
+ version: "0.1.15",
+ packagePath: cliPackagePath,
+ globalRootPath: globalRoot,
+ isLinked: false,
+ });
+ expect(calls).toEqual([
+ {
+ command: "npm",
+ args: ["-g", "ls", "@getpaseo/cli", "--json", "--depth=0", "--long"],
+ timeout: 10_000,
+ maxBuffer: 10 * 1024 * 1024,
+ },
+ ]);
+ });
+
+ test("runs the global install command for the latest cli", async () => {
+ const calls: CommandCall[] = [];
+ const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
+ calls.push({
+ command,
+ args,
+ timeout: options?.timeout,
+ maxBuffer: options?.maxBuffer,
+ });
+ return { exitCode: 0, stdout: "changed 42 packages", stderr: "" };
+ });
+
+ await expect(cli.installLatest()).resolves.toEqual({
+ exitCode: 0,
+ stdout: "changed 42 packages",
+ stderr: "",
+ });
+ expect(calls).toEqual([
+ {
+ command: "npm",
+ args: ["install", "-g", "@getpaseo/cli@latest"],
+ timeout: 300_000,
+ maxBuffer: 10 * 1024 * 1024,
+ },
+ ]);
+ });
+
+ test("reports missing npm when npm exits without JSON", async () => {
+ const cli = new DefaultNpmGlobalPaseoCli(async () => ({
+ exitCode: 127,
+ stdout: "",
+ stderr: "npm: command not found",
+ }));
+
+ await expect(cli.inspect()).rejects.toThrow("npm: command not found");
+ });
+
+ test("reports missing global cli when npm output has no cli dependency", async () => {
+ const cli = new DefaultNpmGlobalPaseoCli(async () => ({
+ exitCode: 1,
+ stdout: JSON.stringify({ name: "lib", path: globalRoot, dependencies: {} }),
+ stderr: "missing",
+ }));
+
+ await expect(cli.inspect()).rejects.toThrow(
+ "@getpaseo/cli is not installed with npm -g on this host",
+ );
+ });
+});
diff --git a/packages/server/src/server/session/daemon/npm-global-cli.ts b/packages/server/src/server/session/daemon/npm-global-cli.ts
new file mode 100644
index 000000000..d1e044405
--- /dev/null
+++ b/packages/server/src/server/session/daemon/npm-global-cli.ts
@@ -0,0 +1,147 @@
+import { getErrorMessage } from "@getpaseo/protocol/error-utils";
+import { z } from "zod";
+import { execCommand } from "../../../utils/spawn.js";
+
+export const PASEO_CLI_PACKAGE = "@getpaseo/cli";
+
+const NPM_PROBE_TIMEOUT_MS = 10_000;
+const NPM_INSTALL_TIMEOUT_MS = 300_000;
+const NPM_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
+
+const NpmGlobalListSchema = z
+ .object({
+ path: z.string().optional(),
+ dependencies: z.record(z.string(), z.unknown()).optional(),
+ })
+ .passthrough();
+
+const NpmGlobalCliPackageSchema = z
+ .object({
+ version: z.string(),
+ path: z.string(),
+ link: z.boolean().optional(),
+ })
+ .passthrough();
+
+const CommandErrorSchema = z
+ .object({
+ code: z.union([z.number(), z.string()]).optional(),
+ stdout: z.string().optional(),
+ stderr: z.string().optional(),
+ })
+ .passthrough();
+
+export interface CommandOptions {
+ timeout?: number;
+ maxBuffer?: number;
+}
+
+export interface CommandResult {
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+}
+
+export interface NpmGlobalPaseoInstall {
+ version: string;
+ packagePath: string;
+ globalRootPath: string | null;
+ isLinked: boolean;
+}
+
+export interface NpmGlobalPaseoCli {
+ inspect(): Promise;
+ installLatest(): Promise;
+}
+
+export type CommandRunner = (
+ command: string,
+ args: string[],
+ options?: CommandOptions,
+) => Promise;
+
+async function runExternalCommand(
+ command: string,
+ args: string[],
+ options?: CommandOptions,
+): Promise {
+ try {
+ const { stdout, stderr } = await execCommand(command, args, {
+ timeout: options?.timeout,
+ maxBuffer: options?.maxBuffer,
+ });
+ return { exitCode: 0, stdout, stderr };
+ } catch (error) {
+ const parsed = CommandErrorSchema.safeParse(error);
+ if (!parsed.success) {
+ return { exitCode: 1, stdout: "", stderr: getErrorMessage(error) };
+ }
+
+ return {
+ exitCode: typeof parsed.data.code === "number" ? parsed.data.code : 1,
+ stdout: parsed.data.stdout ?? "",
+ stderr: parsed.data.stderr || getErrorMessage(error),
+ };
+ }
+}
+
+function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | null {
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(stdout);
+ } catch {
+ return null;
+ }
+
+ const list = NpmGlobalListSchema.safeParse(parsedJson);
+ if (!list.success) {
+ return null;
+ }
+
+ const rawCliPackage = list.data.dependencies?.[PASEO_CLI_PACKAGE];
+ const cliPackage = NpmGlobalCliPackageSchema.safeParse(rawCliPackage);
+ if (!cliPackage.success) {
+ return null;
+ }
+
+ return {
+ version: cliPackage.data.version,
+ packagePath: cliPackage.data.path,
+ globalRootPath: list.data.path ?? null,
+ isLinked: cliPackage.data.link === true,
+ };
+}
+
+export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
+ constructor(private readonly runCommand: CommandRunner = runExternalCommand) {}
+
+ async inspect(): Promise {
+ const result = await this.runCommand(
+ "npm",
+ ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"],
+ {
+ timeout: NPM_PROBE_TIMEOUT_MS,
+ maxBuffer: NPM_MAX_BUFFER_BYTES,
+ },
+ );
+
+ if (result.exitCode !== 0 && result.stdout.trim().length === 0) {
+ throw new Error(result.stderr.trim() || "npm is not available on this host");
+ }
+
+ const install = parseNpmGlobalPaseoInstall(result.stdout);
+ if (!install) {
+ throw new Error(`${PASEO_CLI_PACKAGE} is not installed with npm -g on this host`);
+ }
+ return install;
+ }
+
+ installLatest(): Promise {
+ return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], {
+ timeout: NPM_INSTALL_TIMEOUT_MS,
+ maxBuffer: NPM_MAX_BUFFER_BYTES,
+ });
+ }
+}
+
+export const npmGlobalPaseoCli = new DefaultNpmGlobalPaseoCli();
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index 01e0b260f..c2eb412b2 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,
},
};
}
diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts
index 0554af692..36cfa7ef8 100644
--- a/packages/server/src/utils/directory-suggestions.ts
+++ b/packages/server/src/utils/directory-suggestions.ts
@@ -1,6 +1,7 @@
import type { Dirent } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
+import { isPathInsideRoot } from "./path.js";
export interface SearchHomeDirectoriesOptions {
homeDir: string;
@@ -786,11 +787,6 @@ function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
return relative.split(path.sep).join("/");
}
-function isPathInsideRoot(root: string, target: string): boolean {
- const relative = path.relative(root, target);
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
-}
-
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
const typedQuery = query.trim().replace(/\\/g, "/");
let normalized = typedQuery;
diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts
index 644b436cc..d84a1bf6a 100644
--- a/packages/server/src/utils/path.test.ts
+++ b/packages/server/src/utils/path.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-import { areEquivalentPaths, createPathEquivalenceMatcher } from "./path.js";
+import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
describe("path equivalence", () => {
test.each([
@@ -19,4 +19,18 @@ describe("path equivalence", () => {
areEquivalentPaths("/Users/Administrator/GhostFactory", "/users/administrator/ghostfactory"),
).toBe(false);
});
+
+ test("checks POSIX root containment without prefix false positives", () => {
+ expect(isPathInsideRoot("/opt/paseo", "/opt/paseo/node_modules/@getpaseo/server")).toBe(true);
+ expect(isPathInsideRoot("/opt/paseo", "/opt/paseo-other")).toBe(false);
+ });
+
+ test("checks Windows root containment case-insensitively", () => {
+ expect(
+ isPathInsideRoot("C:\\Paseo\\node_modules", "c:/paseo/node_modules/@getpaseo/server"),
+ ).toBe(true);
+ expect(isPathInsideRoot("C:\\Paseo\\node_modules", "C:\\Paseo\\node_modules-other")).toBe(
+ false,
+ );
+ });
});
diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts
index 2ee49fa95..f4d87a92a 100644
--- a/packages/server/src/utils/path.ts
+++ b/packages/server/src/utils/path.ts
@@ -58,6 +58,25 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
};
}
+export function isPathInsideRoot(root: string, candidate: string): boolean {
+ const compareAsWindows = shouldCompareAsWindows(root, candidate);
+ const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
+ const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
+ const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
+ const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
+
+ return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
+}
+
+export function isRealpathInsideRoot(root: string, candidate: string): boolean {
+ const rootVariants = collectPathVariants(root);
+ const candidateVariants = collectPathVariants(candidate);
+
+ return rootVariants.some((rootVariant) =>
+ candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
+ );
+}
+
function collectPathVariants(value: string): string[] {
const variants = new Set([value]);
for (const realpath of resolveRealpathVariants(value)) {