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, }, }; }