From 9fe7a3ebb73a5b0cbdf65ab9796c9277b88a6d15 Mon Sep 17 00:00:00 2001
From: Christopher Mead <chrisrmead@comcast.net>
Date: Fri, 10 Jan 2025 15:25:57 -0700
Subject: [PATCH 1/3] E2E tests: reticulate stop/restart test (#5956)

Test stop/restart functionality with reticulate.

Web only as electron uses native dialog as part of process

### QA Notes

All tests pass.
---
 test/e2e/infra/fixtures/interpreter.ts       | 10 +--
 test/e2e/tests/_test.setup.ts                |  6 +-
 test/e2e/tests/reticulate/reticulate.test.ts | 77 ++++++++++++++++----
 3 files changed, 69 insertions(+), 24 deletions(-)

diff --git a/test/e2e/infra/fixtures/interpreter.ts b/test/e2e/infra/fixtures/interpreter.ts
index 2d3c91070c0..b247cea065c 100644
--- a/test/e2e/infra/fixtures/interpreter.ts
+++ b/test/e2e/infra/fixtures/interpreter.ts
@@ -46,14 +46,8 @@ export class Interpreter {
 
 		await test.step(`Select interpreter via Quick Access: ${interpreterType}`, async () => {
 			interpreterType === 'Python'
-				? await this.console.selectInterpreter(InterpreterType.Python, DESIRED_PYTHON)
-				: await this.console.selectInterpreter(InterpreterType.R, DESIRED_R);
-
-			if (waitForReady) {
-				interpreterType === 'Python'
-					? await this.console.waitForReady('>>>', 30000)
-					: await this.console.waitForReady('>', 30000);
-			}
+				? await this.console.selectInterpreter(InterpreterType.Python, DESIRED_PYTHON, waitForReady)
+				: await this.console.selectInterpreter(InterpreterType.R, DESIRED_R, waitForReady);
 		});
 	};
 
diff --git a/test/e2e/tests/_test.setup.ts b/test/e2e/tests/_test.setup.ts
index 153d88e80e5..c792ff1876d 100644
--- a/test/e2e/tests/_test.setup.ts
+++ b/test/e2e/tests/_test.setup.ts
@@ -109,13 +109,13 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 	}, { scope: 'worker', auto: true, timeout: 60000 }],
 
 	interpreter: [async ({ app, page }, use) => {
-		const setInterpreter = async (desiredInterpreter: 'Python' | 'R') => {
+		const setInterpreter = async (desiredInterpreter: 'Python' | 'R', waitForReady = true) => {
 			const currentInterpreter = await page.locator('.top-action-bar-interpreters-manager').textContent() || '';
 
 			if (!currentInterpreter.startsWith(desiredInterpreter)) {
 				desiredInterpreter === 'Python'
-					? await app.workbench.interpreter.startInterpreterViaQuickAccess('Python')
-					: await app.workbench.interpreter.startInterpreterViaQuickAccess('R');
+					? await app.workbench.interpreter.startInterpreterViaQuickAccess('Python', waitForReady)
+					: await app.workbench.interpreter.startInterpreterViaQuickAccess('R', waitForReady);
 			}
 		};
 
diff --git a/test/e2e/tests/reticulate/reticulate.test.ts b/test/e2e/tests/reticulate/reticulate.test.ts
index 9cee32559a5..d33bdbcc9af 100644
--- a/test/e2e/tests/reticulate/reticulate.test.ts
+++ b/test/e2e/tests/reticulate/reticulate.test.ts
@@ -19,10 +19,7 @@ test.describe('Reticulate', {
 }, () => {
 	test.beforeAll(async function ({ app, userSettings }) {
 		try {
-			// remove this once https://github.com/posit-dev/positron/issues/5226
-			// is resolved
 			await userSettings.set([
-				['kernelSupervisor.enable', 'false'],
 				['positron.reticulate.enabled', 'true']
 			]);
 
@@ -32,6 +29,10 @@ test.describe('Reticulate', {
 		}
 	});
 
+	// if running tests in sequence, we will need to skip waiting for ready because interpreters
+	// will already be running
+	let sequential = false;
+
 	test('R - Verify Basic Reticulate Functionality [C...]', async function ({ app, r, interpreter }) {
 
 		await app.workbench.console.pasteCodeToConsole('reticulate::repl_python()');
@@ -46,19 +47,69 @@ test.describe('Reticulate', {
 		}
 
 		await app.workbench.console.waitForReady('>>>');
-		await app.workbench.console.pasteCodeToConsole('x=100');
-		await app.workbench.console.sendEnterKey();
 
-		await interpreter.set('R');
+		await verifyReticulateFunctionality(app, interpreter, false);
 
-		await app.workbench.console.pasteCodeToConsole('y<-reticulate::py$x');
-		await app.workbench.console.sendEnterKey();
-		await app.workbench.layouts.enterLayout('fullSizedAuxBar');
+		sequential = true;
+
+	});
+
+	test('R - Verify Reticulate Stop/Restart Functionality [C...]', async function ({ app, interpreter }) {
+
+		// web only test but we don't have another way to skip electron tests
+		if (!app.web) {
+			return;
+		}
+
+		await app.workbench.interpreter.selectInterpreter('Python', 'Python (reticulate)', !sequential);
+
+		await verifyReticulateFunctionality(app, interpreter, sequential);
+
+		await app.workbench.layouts.enterLayout('stacked');
+
+		await app.workbench.console.barPowerButton.click();
+
+		await app.workbench.console.waitForConsoleContents('shut down successfully');
+
+		await app.code.driver.page.locator('.positron-console').getByRole('button', { name: 'Restart R' }).click();
+
+		await app.workbench.console.waitForReady('>');
 
-		await expect(async () => {
-			const variablesMap = await app.workbench.variables.getFlatVariables();
-			expect(variablesMap.get('y')).toStrictEqual({ value: '100', type: 'int' });
-		}).toPass({ timeout: 60000 });
+		await app.code.driver.page.locator('.positron-console').locator('.action-bar-button-drop-down-arrow').click();
+
+		await app.code.driver.page.locator('.action-label', { hasText: 'Python (reticulate)' }).hover();
+
+		await app.code.driver.page.keyboard.press('Enter');
+
+		await app.code.driver.page.locator('.positron-console').getByRole('button', { name: 'Restart Python' }).click();
+
+		await app.workbench.console.waitForReady('>>>');
+
+		await verifyReticulateFunctionality(app, interpreter, sequential);
 
 	});
 });
+
+async function verifyReticulateFunctionality(app, interpreter, sequential) {
+
+	await app.workbench.console.pasteCodeToConsole('x=100');
+	await app.workbench.console.sendEnterKey();
+
+	await app.workbench.console.barClearButton.click();
+
+	await interpreter.set('R', !sequential);
+
+	await app.workbench.console.pasteCodeToConsole('y<-reticulate::py$x');
+	await app.workbench.console.sendEnterKey();
+
+	await app.workbench.console.barClearButton.click();
+
+	await app.workbench.layouts.enterLayout('fullSizedAuxBar');
+
+	await expect(async () => {
+		const variablesMap = await app.workbench.variables.getFlatVariables();
+		expect(variablesMap.get('y')).toStrictEqual({ value: '100', type: 'int' });
+	}).toPass({ timeout: 60000 });
+
+	await app.workbench.layouts.enterLayout('stacked');
+};

From 11d5ddf70837de196e5146555f80ea39ddc4bf84 Mon Sep 17 00:00:00 2001
From: Dhruvi Sompura <dhruvi.sompura@posit.co>
Date: Fri, 10 Jan 2025 15:05:09 -0800
Subject: [PATCH 2/3] Track all extension hosts in runtime startup service
 (#5939)

### Description

Addresses #5718

The web build of Positron was showing the "No interpreters found" dialog
to users even if an interpreter was found. This bug is only present on
the web build because there are two extension hosts for the web build.
VS Code has a 'web extension host' that runs installed extensions in the
browser. Thus the Positron web build has this additional 'web extension
host'.

For our case, the 'web extension host' will never find any interpreters
during the runtime startup process. When the 'web extension host'
completes discovery before the extension host does, our list of
registered runtimes will be empty which causes the "No interpreters
found" dialog to be shown.

The runtime startup service needs to know about the existence of all
extension hosts and wait for all of the extension hosts to complete
their own language runtime discovery.

### Release Notes


#### New Features

- N/A

#### Bug Fixes

- The web build of Positron no longer shows the "No interpreters found"
dialog when interpreters are found. The runtime startup process now
waits for all extension hosts to complete runtime discovery.


### QA Notes
- [ ] Verify the "No interpreters found" dialog is not shown when there
are interpreters.
- [ ] Verify the "No interpreters found" dialog is shown only ONCE when
there aren't any interpreters.
  -  The following scenarios cover when the dialog can be shown:
    - There are no Python/R installations at all
- There are only outdated versions of Python/R installed which we do not
support, e.g. only having R 4.1 installed on the machine (we currently
support 4.2+)
- The Python/R installations are in hidden locations that Positron does
not check

### Screenshots

**Web Build - No Interpreters**


https://github.com/user-attachments/assets/5247d580-5eb7-44d5-8b75-1ff7b1f29565


**Web Build - Interpreters Found**


https://github.com/user-attachments/assets/adaa925b-5b0b-4f2e-bd7b-2690853138af
---
 .../positron/mainThreadLanguageRuntime.ts     | 18 +++--
 .../runtimeStartup/common/runtimeStartup.ts   | 68 +++++++++++++++++--
 .../common/runtimeStartupService.ts           | 29 +++++++-
 3 files changed, 102 insertions(+), 13 deletions(-)

diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
index ad7785b6da4..26453a8a942 100644
--- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
+++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
@@ -1168,11 +1168,15 @@ export class MainThreadLanguageRuntime
 		this._proxy = extHostContext.getProxy(ExtHostPositronContext.ExtHostLanguageRuntime);
 		this._id = MainThreadLanguageRuntime.MAX_ID++;
 
-		this._runtimeStartupService.onDidChangeRuntimeStartupPhase((phase) => {
-			if (phase === RuntimeStartupPhase.Discovering) {
-				this._proxy.$discoverLanguageRuntimes();
-			}
-		});
+		this._runtimeStartupService.registerMainThreadLanguageRuntime(this._id);
+
+		this._disposables.add(
+			this._runtimeStartupService.onDidChangeRuntimeStartupPhase((phase) => {
+				if (phase === RuntimeStartupPhase.Discovering) {
+					this._proxy.$discoverLanguageRuntimes();
+				}
+			})
+		);
 
 		this._disposables.add(this._runtimeSessionService.registerSessionManager(this));
 	}
@@ -1248,7 +1252,7 @@ export class MainThreadLanguageRuntime
 
 	// Signals that language runtime discovery is complete.
 	$completeLanguageRuntimeDiscovery(): void {
-		this._runtimeStartupService.completeDiscovery();
+		this._runtimeStartupService.completeDiscovery(this._id);
 	}
 
 	$unregisterLanguageRuntime(handle: number): void {
@@ -1278,6 +1282,8 @@ export class MainThreadLanguageRuntime
 				session.emitExit(exit);
 			}
 		});
+
+		this._runtimeStartupService.unregisterMainThreadLanguageRuntime(this._id);
 		this._disposables.dispose();
 	}
 
diff --git a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
index ee7d96b4189..34efeb8b899 100644
--- a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
+++ b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
@@ -84,6 +84,12 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 	// (metadata.languageId) of the runtime.
 	private readonly _mostRecentlyStartedRuntimesByLanguageId = new Map<string, ILanguageRuntimeMetadata>();
 
+	// A map of each extension host and its runtime discovery completion state.
+	// This is keyed by the the extension host's mainThreadLanguageRuntime's id
+	// This map is used to determine if runtime discovery has been completed
+	// across all extension hosts.
+	private readonly _discoveryCompleteByExtHostId = new Map<number, boolean>();
+
 	// The current startup phase; an observeable value.
 	private _startupPhase: ISettableObservable<RuntimeStartupPhase>;
 
@@ -344,11 +350,65 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 	}
 
 	/**
-	 * Completes the language runtime discovery phase. If no runtimes were
-	 * started or will be started, automatically start one.
+	 * Signals that the runtime discovery phase is completed only after all
+	 * extension hosts have completed runtime discovery.
+	 *
+	 * If no runtimes were started or will be started, automatically start one.
+	 */
+	public completeDiscovery(id: number): void {
+		// Update the extension host's runtime discovery state to 'Complete'
+		this._discoveryCompleteByExtHostId.set(id, true);
+		this._logService.debug(`[Runtime startup] Discovery completed for extension host with id: ${id}.`);
+
+		// Determine if all extension hosts have completed discovery
+		let discoveryCompletedByAllExtensionHosts = true;
+		for (const disoveryCompleted of this._discoveryCompleteByExtHostId.values()) {
+			if (!disoveryCompleted) {
+				discoveryCompletedByAllExtensionHosts = false;
+				break;
+			}
+		}
+
+		// The 'Discovery' phase is considered complete only after all extension hosts
+		// have signaled they have completed their own runtime discovery
+		if (discoveryCompletedByAllExtensionHosts) {
+			this._startupPhase.set(RuntimeStartupPhase.Complete, undefined);
+			// Reset the discovery state for each ext host so we are ready
+			// for possible re-discovery of runtimes
+			this._discoveryCompleteByExtHostId.forEach((_, extHostId, m) => {
+				m.set(extHostId, false);
+			});
+		}
+	}
+
+	/**
+	 * Used to register an instance of a MainThreadLanguageRuntime.
+	 *
+	 * This is required because there can be multiple extension hosts
+	 * and the startup service needs to know of all of them to track
+	 * the startup phase across all extension hosts.
+	 *
+	 * @param id The id of the MainThreadLanguageRuntime instance being registered.
+	 */
+	public registerMainThreadLanguageRuntime(id: number): void {
+		// Add the mainThreadLanguageRuntime instance id to the set of mainThreadLanguageRuntimes.
+		this._discoveryCompleteByExtHostId.set(id, false);
+		this._logService.debug(`[Runtime startup] Registered extension host with id: ${id}.`);
+	}
+
+	/**
+	 * Used to un-registers an instance of a MainThreadLanguageRuntime.
+	 *
+	 * This is required because there can be multiple extension hosts
+	 * and the startup service needs to know of all of them to track
+	 * the startup phase across all extension hosts.
+	 *
+	 * @param id The id of the MainThreadLanguageRuntime instance being un-registered.
 	 */
-	completeDiscovery(): void {
-		this._startupPhase.set(RuntimeStartupPhase.Complete, undefined);
+	public unregisterMainThreadLanguageRuntime(id: number): void {
+		// Remove the mainThreadLanguageRuntime instance id to the set of mainThreadLanguageRuntimes.
+		this._discoveryCompleteByExtHostId.delete(id);
+		this._logService.debug(`[Runtime startup] Unregistered extension host with id: ${id}.`);
 	}
 
 	/**
diff --git a/src/vs/workbench/services/runtimeStartup/common/runtimeStartupService.ts b/src/vs/workbench/services/runtimeStartup/common/runtimeStartupService.ts
index d01a0a1671a..2c3e61849b7 100644
--- a/src/vs/workbench/services/runtimeStartup/common/runtimeStartupService.ts
+++ b/src/vs/workbench/services/runtimeStartup/common/runtimeStartupService.ts
@@ -100,8 +100,31 @@ export interface IRuntimeStartupService {
 	getAffiliatedRuntimeMetadata(languageId: string): ILanguageRuntimeMetadata | undefined;
 
 	/**
-	 * Signal that discovery of language runtimes is complete. Called from the
-	 * extension host.
+	 * Signal that discovery of language runtimes is completed for an extension host.
+	 *
+	 * @param id the id of the MainThreadLanguageRuntime instance for the extension host
+	 */
+	completeDiscovery(id: number): void;
+
+	/**
+	 * Used to register an instance of a MainThreadLanguageRuntime.
+	 *
+	 * This is required because there can be multiple extension hosts
+	 * and the startup service needs to know of all of them to track
+	 * the startup phase across all extension hosts.
+	 *
+	 * @param id The id of the MainThreadLanguageRuntime instance for the extension host.
+	 */
+	registerMainThreadLanguageRuntime(id: number): void;
+
+	/**
+	 * Used to un-register an instance of a MainThreadLanguageRuntime.
+	 *
+	 * This is required because there can be multiple extension hosts
+	 * and the startup service needs to know of all of them to track
+	 * the startup phase across all extension hosts.
+	 *
+	 * @param id The id of the MainThreadLanguageRuntime instance for the extension host.
 	 */
-	completeDiscovery(): void;
+	unregisterMainThreadLanguageRuntime(id: number): void;
 }

From 968a8acfff4300e6f4cf1cc8004507b97aa8e78b Mon Sep 17 00:00:00 2001
From: Jonathan <jonathan@rstudio.com>
Date: Fri, 10 Jan 2025 15:09:52 -0800
Subject: [PATCH 3/3] Add option to leave kernel sessions running when Positron
 is closed (#5899)

This change makes it possible to close Positron but leave Python or R
running, to be resumed/reconnected when Positron is opened again. A new
setting controls this behavior; it lets you specify how long to let
sessions run idle in the background before they are automatically
closed.

<img width="734" alt="image"
src="https://github.com/user-attachments/assets/88b1dce9-fdfd-4cef-949e-1244c2ba94ea"
/>

- Enables long-running remote kernels that can be accessed over SSH
- For advanced users, enables faster startup and the ability to safely
close Positron Desktop without losing data or interrupting computations
- Addresses an issue with accumulated orphaned supervisor process
observed in some dev environments; these processes will now clean
themselves up after an hour
- Enables testing of various "leave and come back later" scenarios on
Positron Desktop that could formerly only be tested in server and/or
Workbench configurations

### How it Works

1. When sessions are not specified to be closed when Positron is closed,
the kernel supervisor is:
1. started with a new `--idle-shutdown-hours` flag so that it shuts down
on its own after all sessions have been idle for the specified number of
hours
2. started with `nohup` (Unix-alike) or `start /b` (Windows) so that it
continues running outside the terminal host after Positron closes
2. Persistent sessions are given a new `SessionLocation`: `Machine`
3. Positron saves information about persistent sessions to durable
workspace storage rather than ephemeral storage
4. At startup, Positron checks all the persistent sessions to see if
they are still valid (i.e. are still running). It reconnects to any that
are, in the same way that it would reconnect to a session after a
reload.

Note that all of this new behavior is opt-in; if the setting is left at
its default, Positron behaves the same way it does today.

### Release Notes

#### New Features

- Option to leave Python or R running when Positron is closed, for
remote sessions or long-running computations (#4996)

#### Bug Fixes

- N/A

### QA Notes

- This change should not impact anything when the new setting is left at
its default value.
- If the kernel session exits while Positron is closed, Positron should
not barf when it is reopened; instead, it should just start a new kernel
session.
- You really do need a full Positron restart when turning this setting
on. Once on, you will find that Positron continues to connect to the
persistent sessions until they time out or exited, even after turning
the setting off, since the setting only applies to new sessions created
in the new Positron window after changing the setting.
- Some test speedup may be possible with this setting since it lets you
repeatedly open and close Positron without waiting for runtime startup.
(The downside, of course, is that subsequent opens aren't starting with
a clean slate, which may or may not be important).
- On Remote SSH, sessions will only persist when started inside a
folder/workspace as the blank/empty workspace is effectively re-created
every time you use it.

---------

Signed-off-by: Jonathan <jonathan@posit.co>
Co-authored-by: sharon <sharon-wang@users.noreply.github.com>
---
 .../src/client/jupyter-adapter.d.ts           |   5 +
 .../src/client/positron/manager.ts            |  27 +++-
 .../src/client/positron/runtime.ts            |  14 +-
 .../positron-r/src/jupyter-adapter.d.ts       |   8 +-
 extensions/positron-r/src/provider.ts         |  10 +-
 extensions/positron-r/src/runtime-manager.ts  |  26 +++-
 .../positron-reticulate/src/extension.ts      |   8 ++
 extensions/positron-supervisor/package.json   |  28 +++-
 .../positron-supervisor/package.nls.json      |   9 ++
 .../src/KallichoreAdapterApi.ts               | 136 ++++++++++++++++--
 .../src/jupyter-adapter.d.ts                  |   5 +
 src/positron-dts/positron.d.ts                |  22 ++-
 .../positron/mainThreadLanguageRuntime.ts     |  11 +-
 .../positron/extHost.positron.protocol.ts     |   5 +-
 .../common/positron/extHostLanguageRuntime.ts |  31 +++-
 .../common/positron/extHostTypes.positron.ts  |  11 +-
 .../common/languageRuntimeService.ts          |  11 +-
 .../runtimeSession/common/runtimeSession.ts   |  25 ++++
 .../common/runtimeSessionService.ts           |  15 ++
 .../runtimeStartup/common/runtimeStartup.ts   | 131 ++++++++++++++---
 .../common/positronWorkbenchTestServices.ts   |   4 +
 21 files changed, 497 insertions(+), 45 deletions(-)

diff --git a/extensions/positron-python/src/client/jupyter-adapter.d.ts b/extensions/positron-python/src/client/jupyter-adapter.d.ts
index d82e85fc32c..eab351aa3c4 100644
--- a/extensions/positron-python/src/client/jupyter-adapter.d.ts
+++ b/extensions/positron-python/src/client/jupyter-adapter.d.ts
@@ -144,6 +144,11 @@ export interface JupyterAdapterApi extends vscode.Disposable {
         extra?: JupyterKernelExtra | undefined,
     ): Promise<JupyterLanguageRuntimeSession>;
 
+    /**
+     * Validate an existing session for a Jupyter-compatible kernel.
+     */
+    validateSession(sessionId: string): Promise<boolean>;
+
     /**
      * Restore a session for a Jupyter-compatible kernel.
      *
diff --git a/extensions/positron-python/src/client/positron/manager.ts b/extensions/positron-python/src/client/positron/manager.ts
index 45f1c599bbe..30b863efe83 100644
--- a/extensions/positron-python/src/client/positron/manager.ts
+++ b/extensions/positron-python/src/client/positron/manager.ts
@@ -1,5 +1,5 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 /* eslint-disable global-require */
@@ -7,6 +7,7 @@
 import * as portfinder from 'portfinder';
 // eslint-disable-next-line import/no-unresolved
 import * as positron from 'positron';
+import * as vscode from 'vscode';
 import * as path from 'path';
 import * as os from 'os';
 
@@ -254,6 +255,30 @@ export class PythonRuntimeManager implements IPythonRuntimeManager {
         return registeredMetadata ?? metadata;
     }
 
+    /**
+     * Validate an existing session for a Jupyter-compatible kernel.
+     *
+     * @param sessionId The session ID to validate
+     * @returns True if the session is valid, false otherwise
+     */
+    async validateSession(sessionId: string): Promise<boolean> {
+        const config = vscode.workspace.getConfiguration('kernelSupervisor');
+        if (config.get<boolean>('enable', true)) {
+            const ext = vscode.extensions.getExtension('positron.positron-supervisor');
+            if (!ext) {
+                throw new Error('Positron Supervisor extension not found');
+            }
+            if (!ext.isActive) {
+                await ext.activate();
+            }
+            return ext.exports.validateSession(sessionId);
+        }
+
+        // When not using the kernel supervisor, sessions are not
+        // persisted.
+        return false;
+    }
+
     /**
      * Wrapper for Python runtime discovery method that caches the metadata
      * before it's returned to Positron.
diff --git a/extensions/positron-python/src/client/positron/runtime.ts b/extensions/positron-python/src/client/positron/runtime.ts
index 0d7c91cbae1..189608aa46a 100644
--- a/extensions/positron-python/src/client/positron/runtime.ts
+++ b/extensions/positron-python/src/client/positron/runtime.ts
@@ -1,10 +1,11 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 /* eslint-disable global-require */
 // eslint-disable-next-line import/no-unresolved
 import * as positron from 'positron';
+import * as vscode from 'vscode';
 import * as fs from 'fs-extra';
 import * as os from 'os';
 import * as path from 'path';
@@ -104,6 +105,15 @@ export async function createPythonRuntimeMetadata(
         pythonEnvironmentId: interpreter.id || '',
     };
 
+    // Check the kernel supervisor's configuration; if it's enabled and
+    // configured to persist sessions, mark the session location as 'machine'
+    // so that Positron will reattach to the session after Positron is reopened.
+    const config = vscode.workspace.getConfiguration('kernelSupervisor');
+    const sessionLocation =
+        config.get<boolean>('enable', true) && config.get<string>('shutdownTimeout', 'immediately') !== 'immediately'
+            ? positron.LanguageRuntimeSessionLocation.Machine
+            : positron.LanguageRuntimeSessionLocation.Workspace;
+
     // Create the metadata for the language runtime
     const metadata: positron.LanguageRuntimeMetadata = {
         runtimeId,
@@ -119,7 +129,7 @@ export async function createPythonRuntimeMetadata(
             .readFileSync(path.join(EXTENSION_ROOT_DIR, 'resources', 'branding', 'python-icon.svg'))
             .toString('base64'),
         startupBehavior,
-        sessionLocation: positron.LanguageRuntimeSessionLocation.Workspace,
+        sessionLocation,
         extraRuntimeData,
     };
 
diff --git a/extensions/positron-r/src/jupyter-adapter.d.ts b/extensions/positron-r/src/jupyter-adapter.d.ts
index 724f5b4f95d..b780375874e 100644
--- a/extensions/positron-r/src/jupyter-adapter.d.ts
+++ b/extensions/positron-r/src/jupyter-adapter.d.ts
@@ -1,11 +1,10 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
 import * as vscode from 'vscode';
 
-// eslint-disable-next-line import/no-unresolved
 import * as positron from 'positron';
 
 export interface JupyterSessionState {
@@ -149,6 +148,11 @@ export interface JupyterAdapterApi extends vscode.Disposable {
 		extra?: JupyterKernelExtra | undefined,
 	): Promise<JupyterLanguageRuntimeSession>;
 
+	/**
+	 * Validate an existing session for a Jupyter-compatible kernel.
+	 */
+	validateSession(sessionId: string): Promise<boolean>;
+
 	/**
 	 * Restore a session for a Jupyter-compatible kernel.
 	 *
diff --git a/extensions/positron-r/src/provider.ts b/extensions/positron-r/src/provider.ts
index 0c0d12edc0c..3b46ad5d6bf 100644
--- a/extensions/positron-r/src/provider.ts
+++ b/extensions/positron-r/src/provider.ts
@@ -216,6 +216,14 @@ export async function makeMetadata(
 		reasonDiscovered: rInst.reasonDiscovered,
 	};
 
+	// Check the kernel supervisor's configuration; if it's enabled and
+	// configured to persist sessions, mark the session location as 'machine'
+	// so that Positron will reattach to the session after Positron is reopened.
+	const config = vscode.workspace.getConfiguration('kernelSupervisor');
+	const sessionLocation = config.get<boolean>('enable', true) &&
+		config.get<string>('shutdownTimeout', 'immediately') !== 'immediately' ?
+		positron.LanguageRuntimeSessionLocation.Machine : positron.LanguageRuntimeSessionLocation.Workspace;
+
 	const metadata: positron.LanguageRuntimeMetadata = {
 		runtimeId,
 		runtimeName,
@@ -230,7 +238,7 @@ export async function makeMetadata(
 			fs.readFileSync(
 				path.join(EXTENSION_ROOT_DIR, 'resources', 'branding', 'r-icon.svg')
 			).toString('base64'),
-		sessionLocation: positron.LanguageRuntimeSessionLocation.Workspace,
+		sessionLocation,
 		startupBehavior,
 		extraRuntimeData
 	};
diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts
index 6cff441c6b3..942851f7809 100644
--- a/extensions/positron-r/src/runtime-manager.ts
+++ b/extensions/positron-r/src/runtime-manager.ts
@@ -1,5 +1,5 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
@@ -88,6 +88,30 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager {
 		return Promise.resolve(makeMetadata(inst, positron.LanguageRuntimeStartupBehavior.Immediate));
 	}
 
+	/**
+	 * Validate an existing session for a Jupyter-compatible kernel.
+	 *
+	 * @param sessionId The session ID to validate
+	 * @returns True if the session is valid, false otherwise
+	 */
+	async validateSession(sessionId: string): Promise<boolean> {
+		const config = vscode.workspace.getConfiguration('kernelSupervisor');
+		if (config.get<boolean>('enable', true)) {
+			const ext = vscode.extensions.getExtension('positron.positron-supervisor');
+			if (!ext) {
+				throw new Error('Positron Supervisor extension not found');
+			}
+			if (!ext.isActive) {
+				await ext.activate();
+			}
+			return ext.exports.validateSession(sessionId);
+		}
+
+		// When not using the kernel supervisor, sessions are not
+		// persisted.
+		return false;
+	}
+
 	restoreSession(
 		runtimeMetadata: positron.LanguageRuntimeMetadata,
 		sessionMetadata: positron.RuntimeSessionMetadata): Thenable<positron.LanguageRuntimeSession> {
diff --git a/extensions/positron-reticulate/src/extension.ts b/extensions/positron-reticulate/src/extension.ts
index 6d1a4898d3d..018a8731717 100644
--- a/extensions/positron-reticulate/src/extension.ts
+++ b/extensions/positron-reticulate/src/extension.ts
@@ -693,6 +693,14 @@ class ReticulateRuntimeMetadata implements positron.LanguageRuntimeMetadata {
 				path.join(CONTEXT.extensionPath, 'resources', 'branding', 'reticulate.svg'),
 				{ encoding: 'base64' }
 			);
+		// Check the kernel supervisor's configuration; if it's enabled and
+		// configured to persist sessions, mark the session location as 'machine'
+		// so that Positron will reattach to the session after Positron is reopened.
+		const config = vscode.workspace.getConfiguration('kernelSupervisor');
+		this.sessionLocation = config.get<boolean>('enable', true) &&
+			config.get<string>('shutdownTimeout', 'immediately') !== 'immediately' ?
+			positron.LanguageRuntimeSessionLocation.Machine : positron.LanguageRuntimeSessionLocation.Workspace;
+
 	}
 	runtimePath: string = 'Managed by the reticulate package';
 	runtimeName: string = 'Python (reticulate)';
diff --git a/extensions/positron-supervisor/package.json b/extensions/positron-supervisor/package.json
index 50ec585b0ac..eaccf1ceca1 100644
--- a/extensions/positron-supervisor/package.json
+++ b/extensions/positron-supervisor/package.json
@@ -64,6 +64,32 @@
           "default": "debug",
           "description": "%configuration.logLevel.description%"
         },
+        "kernelSupervisor.shutdownTimeout": {
+          "scope": "window",
+          "type": "string",
+          "enum": [
+            "immediately",
+            "when idle",
+            "4",
+            "8",
+            "12",
+            "24",
+            "168",
+            "indefinitely"
+          ],
+          "enumDescriptions": [
+            "%configuration.shutdownTimeout.immediately.description%",
+            "%configuration.shutdownTimeout.whenIdle.description%",
+            "%configuration.shutdownTimeout.4.description%",
+            "%configuration.shutdownTimeout.8.description%",
+            "%configuration.shutdownTimeout.12.description%",
+            "%configuration.shutdownTimeout.24.description%",
+            "%configuration.shutdownTimeout.168.description%",
+            "%configuration.shutdownTimeout.indefinitely.description%"
+          ],
+          "default": "immediately",
+          "markdownDescription": "%configuration.shutdownTimeout.description%"
+        },
         "kernelSupervisor.attachOnStartup": {
           "scope": "window",
           "type": "boolean",
@@ -115,7 +141,7 @@
   },
   "positron": {
     "binaryDependencies": {
-      "kallichore": "0.1.26"
+      "kallichore": "0.1.27"
     }
   },
   "dependencies": {
diff --git a/extensions/positron-supervisor/package.nls.json b/extensions/positron-supervisor/package.nls.json
index 6e757e777dc..301511b3be2 100644
--- a/extensions/positron-supervisor/package.nls.json
+++ b/extensions/positron-supervisor/package.nls.json
@@ -7,6 +7,15 @@
 	"configuration.logLevel.debug.description": "Debug messages",
 	"configuration.logLevel.trace.description": "Verbose tracing messages",
 	"configuration.logLevel.description": "Log level for the kernel supervisor (restart Positron to apply)",
+	"configuration.shutdownTimeout.description": "When should kernels be shut down after Positron is closed?\n\nTimeouts are in hours and start once the kernel is idle. Restart Positron to apply.",
+	"configuration.shutdownTimeout.immediately.description": "Shut down kernels immediately when Positron is closed",
+	"configuration.shutdownTimeout.whenIdle.description": "Wait for kernels to finish any running computations, then shut them down",
+	"configuration.shutdownTimeout.4.description": "After idle for 4 hours",
+	"configuration.shutdownTimeout.8.description": "After idle for 8 hours",
+	"configuration.shutdownTimeout.12.description": "After idle for 12 hours",
+	"configuration.shutdownTimeout.24.description": "After idle for 1 day",
+	"configuration.shutdownTimeout.168.description": "After idle for 7 days",
+	"configuration.shutdownTimeout.indefinitely.description": "Leave kernels running indefinitely",
 	"configuration.enable.description": "Run Jupyter kernels under the Positron kernel supervisor.",
 	"configuration.showTerminal.description": "Show the host terminal for the Positron kernel supervisor",
 	"configuration.connectionTimeout.description": "Timeout in seconds for connecting to the kernel's sockets",
diff --git a/extensions/positron-supervisor/src/KallichoreAdapterApi.ts b/extensions/positron-supervisor/src/KallichoreAdapterApi.ts
index 4c1a428bbbe..3488a2162c6 100644
--- a/extensions/positron-supervisor/src/KallichoreAdapterApi.ts
+++ b/extensions/positron-supervisor/src/KallichoreAdapterApi.ts
@@ -1,5 +1,5 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
@@ -81,6 +81,12 @@ export class KCApi implements KallichoreAdapterApi {
 	 */
 	private _terminal: vscode.Terminal | undefined;
 
+	/**
+	 * Whether the server is a new server that was just started in this
+	 * Positron session.
+	 */
+	private _newSupervisor = true;
+
 	/**
 	 * Create a new Kallichore API object.
 	 *
@@ -227,21 +233,85 @@ export class KCApi implements KallichoreAdapterApi {
 
 		// Determine the path to the wrapper script.
 		const wrapperName = os.platform() === 'win32' ? 'supervisor-wrapper.bat' : 'supervisor-wrapper.sh';
-		const wrapperPath = path.join(this._context.extensionPath, 'resources', wrapperName);
+		let wrapperPath = path.join(this._context.extensionPath, 'resources', wrapperName);
+
+		// The first argument to the wrapper script is the path to the log file
+		const shellArgs = [
+			outFile
+		];
+
+		// Check to see if session persistence is enabled; if it is, we want to run the
+		// server with nohup so it doesn't die when the terminal is closed.
+		const shutdownTimeout = config.get<string>('shutdownTimeout', 'immediately');
+		if (shutdownTimeout !== 'immediately') {
+			const kernelWrapper = wrapperPath;
+			if (os.platform() === 'win32') {
+				// Use start /b on Windows to run the server in the background
+				this._log.appendLine(`Running Kallichore server with 'start /b' to persist sessions`);
+				wrapperPath = 'start';
+				shellArgs.unshift('/b', kernelWrapper);
+			} else {
+				// Use nohup as the wrapper on Unix-like systems
+				this._log.appendLine(`Running Kallichore server with nohup to persist sessions`);
+				wrapperPath = 'nohup';
+				shellArgs.unshift(kernelWrapper);
+			}
+		}
+
+		// Add the path to Kallichore itself
+		shellArgs.push(shellPath);
+		shellArgs.push(...[
+			'--port', port.toString(),
+			'--token', tokenPath,
+			'--log-level', logLevel,
+			'--log-file', logFile,
+		]);
+
+		// Compute the appropriate value for the idle shutdown hours setting.
+		//
+		// This setting is primarily used in Remote SSH mode to allow kernel
+		// sessions to persist even when Positron itself is closed. In this
+		// scenario, we want keep the sessions alive for a period of time so
+		// they are still running when the user reconnects to the remote host,
+		// but we don't want them to run forever (unless the user wants to and
+		// understands the implications).
+		if (shutdownTimeout === 'immediately') {
+			// In desktop mode, when not persisting sessions, set the idle
+			// timeout to 1 hour. This is a defensive move since we generally
+			// expect the server to exit when the enclosing terminal closes;
+			// the 1 hour idle timeout ensures that it will eventually exit if
+			// the process is orphaned for any reason.
+			if (vscode.env.uiKind === vscode.UIKind.Desktop) {
+				shellArgs.push('--idle-shutdown-hours', '1');
+			}
+
+			// In web mode, we do not set an idle timeout at all by default,
+			// since it is normal for the front end to be disconnected for long
+			// periods of time.
+		} else if (shutdownTimeout === 'when idle') {
+			// Set the idle timeout to 0 hours, which causes the server to exit
+			// 30 seconds after the last session becomes idle.
+			shellArgs.push('--idle-shutdown-hours', '0');
+		} else if (shutdownTimeout !== 'indefinitely') {
+			// All other values of this setting are numbers that we can pass
+			// directly to the supervisor.
+			try {
+				// Attempt to parse the value as an integer
+				const hours = parseInt(shutdownTimeout, 10);
+				shellArgs.push('--idle-shutdown-hours', hours.toString());
+			} catch (err) {
+				// Should never happen since we provide all the values, but log
+				// it if it does.
+				this._log.appendLine(`Invalid hour value for kernelSupervisor.shutdownTimeout: '${shutdownTimeout}'; persisting sessions indefinitely`);
+			}
+		}
 
 		// Start the server in a new terminal
 		this._log.appendLine(`Starting Kallichore server ${shellPath} on port ${port}`);
 		const terminal = vscode.window.createTerminal({
 			name: 'Kallichore',
 			shellPath: wrapperPath,
-			shellArgs: [
-				outFile,
-				shellPath,
-				'--port', port.toString(),
-				'--token', tokenPath,
-				'--log-level', logLevel,
-				'--log-file', logFile,
-			],
+			shellArgs,
 			env,
 			message: `*** Kallichore Server (${shellPath}) ***`,
 			hideFromUser: !showTerminal,
@@ -444,6 +514,10 @@ export class KCApi implements KallichoreAdapterApi {
 		const status = await this._api.serverStatus();
 		this._started.open();
 		this._log.appendLine(`Kallichore ${status.body.version} server reconnected with ${status.body.sessions} sessions`);
+
+		// Mark this a restored server
+		this._newSupervisor = false;
+
 		return true;
 	}
 
@@ -616,6 +690,48 @@ export class KCApi implements KallichoreAdapterApi {
 		return true;
 	}
 
+	/**
+	 * Validate an existing session for a Jupyter-compatible kernel.
+	 */
+	async validateSession(sessionId: string): Promise<boolean> {
+		// Wait for the server to start if it's not already running
+		await this.ensureStarted();
+
+		// If we started a new server instance, no sessions will be running, so
+		// save the round trip and just return false.
+		if (this._newSupervisor) {
+			return false;
+		}
+		try {
+			// Get the session status from the server
+			const session = await this._api.getSession(sessionId);
+
+			// The session is valid if it's in one of the running states (i.e.
+			// not 'never started' or 'exited').
+			//
+			// Consider: This creates an edge case for sessions that exit
+			// naturally before the idle shutdown timeout has expired. Those
+			// sessions will be considered invalid, so Positron will not
+			// reconnect to them and there will be no way to see any output
+			// they emitted between the time Positron was closed and the time
+			// the session exited.
+			const status = session.body.status;
+			return status !== Status.Exited && status !== Status.Uninitialized;
+		} catch (e) {
+			// Swallow errors; we're just checking to see if the session is
+			// alive.
+			if (e instanceof HttpError && e.response.statusCode === 404) {
+				// This is the expected error if the session is not found
+				return false;
+			}
+
+			// Other errors are unexpected; log them and return false
+			this._log.appendLine(`Error validating session ${sessionId}: ${summarizeError(e)}`);
+		}
+
+		return false;
+	}
+
 	/**
 	 * Restores (reconnects to) an already running session on the Kallichore
 	 * server.
diff --git a/extensions/positron-supervisor/src/jupyter-adapter.d.ts b/extensions/positron-supervisor/src/jupyter-adapter.d.ts
index 724f5b4f95d..39d79811a4b 100644
--- a/extensions/positron-supervisor/src/jupyter-adapter.d.ts
+++ b/extensions/positron-supervisor/src/jupyter-adapter.d.ts
@@ -149,6 +149,11 @@ export interface JupyterAdapterApi extends vscode.Disposable {
 		extra?: JupyterKernelExtra | undefined,
 	): Promise<JupyterLanguageRuntimeSession>;
 
+	/**
+	 * Validate an existing session for a Jupyter-compatible kernel.
+	 */
+	validateSession(sessionId: string): Promise<boolean>;
+
 	/**
 	 * Restore a session for a Jupyter-compatible kernel.
 	 *
diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts
index 030d9c4229a..f088296bbd5 100644
--- a/src/positron-dts/positron.d.ts
+++ b/src/positron-dts/positron.d.ts
@@ -533,9 +533,18 @@ declare module 'positron' {
 	 * An enumeration of possible locations for runtime sessions.
 	 */
 	export enum LanguageRuntimeSessionLocation {
+		/**
+		 * The runtime session is persistent on the machine; it should be
+		 * restored when the workspace is re-opened, and may persist across
+		 * Positron sessions.
+		 */
+		Machine = 'machine',
+
 		/**
 		 * The runtime session is located in the current workspace (usually a
-		 * terminal); it should be restored when the workspace is re-opened.
+		 * terminal); it should be restored when the workspace is re-opened in
+		 * the same Positron session (e.g. during a browser reload or
+		 * reconnect)
 		 */
 		Workspace = 'workspace',
 
@@ -673,6 +682,17 @@ declare module 'positron' {
 		validateMetadata?(metadata: LanguageRuntimeMetadata):
 			Thenable<LanguageRuntimeMetadata>;
 
+		/**
+		 * An optional session validation function. If provided, Positron will
+		 * validate any stored session metadata before reconnecting to the
+		 * session.
+		 *
+		 * @param metadata The metadata to validate
+		 * @returns A Thenable that resolves with true (the session is valid) or
+		 *  false (the session is invalid).
+		 */
+		validateSession?(sessionId: string): Thenable<boolean>;
+
 		/**
 		 * Creates a new runtime session.
 		 *
diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
index 26453a8a942..b865271e8ab 100644
--- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
+++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
@@ -1349,7 +1349,16 @@ export class MainThreadLanguageRuntime
 	 * @param metadata The metadata to validate
 	 */
 	async validateMetadata(metadata: ILanguageRuntimeMetadata): Promise<ILanguageRuntimeMetadata> {
-		return this._proxy.$validateLangaugeRuntimeMetadata(metadata);
+		return this._proxy.$validateLanguageRuntimeMetadata(metadata);
+	}
+
+	/**
+	 * Validates a language runtime sesssion.
+	 *
+	 * @param sessionId The session ID to validate
+	 */
+	async validateSession(metadata: ILanguageRuntimeMetadata, sessionId: string): Promise<boolean> {
+		return this._proxy.$validateLanguageRuntimeSession(metadata, sessionId);
 	}
 
 	/**
diff --git a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts
index 5a5d72593da..c5c8ee4aaf5 100644
--- a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts
+++ b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts
@@ -1,5 +1,5 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
@@ -53,7 +53,8 @@ export interface ExtHostLanguageRuntimeShape {
 	$isHostForLanguageRuntime(runtimeMetadata: ILanguageRuntimeMetadata): Promise<boolean>;
 	$createLanguageRuntimeSession(runtimeMetadata: ILanguageRuntimeMetadata, sessionMetadata: RuntimeSessionMetadata): Promise<RuntimeInitialState>;
 	$restoreLanguageRuntimeSession(runtimeMetadata: ILanguageRuntimeMetadata, sessionMetadata: RuntimeSessionMetadata): Promise<RuntimeInitialState>;
-	$validateLangaugeRuntimeMetadata(metadata: ILanguageRuntimeMetadata): Promise<ILanguageRuntimeMetadata>;
+	$validateLanguageRuntimeMetadata(metadata: ILanguageRuntimeMetadata): Promise<ILanguageRuntimeMetadata>;
+	$validateLanguageRuntimeSession(metadata: ILanguageRuntimeMetadata, sessionId: string): Promise<boolean>;
 	$startLanguageRuntime(handle: number): Promise<ILanguageRuntimeInfo>;
 	$openResource(handle: number, resource: URI | string): Promise<boolean>;
 	$executeCode(handle: number, code: string, id: string, mode: RuntimeCodeExecutionMode, errorBehavior: RuntimeErrorBehavior): void;
diff --git a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
index 1f52b3f80b3..b0bec5a1afb 100644
--- a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
+++ b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
@@ -134,7 +134,7 @@ export class ExtHostLanguageRuntime implements extHostProtocol.ExtHostLanguageRu
 	 * @param metadata The metadata to validate
 	 * @returns An updated metadata object
 	 */
-	async $validateLangaugeRuntimeMetadata(metadata: ILanguageRuntimeMetadata):
+	async $validateLanguageRuntimeMetadata(metadata: ILanguageRuntimeMetadata):
 		Promise<ILanguageRuntimeMetadata> {
 		// Find the runtime manager that should be used for this metadata
 		const m = await this.runtimeManagerForRuntime(metadata, true);
@@ -161,6 +161,35 @@ export class ExtHostLanguageRuntime implements extHostProtocol.ExtHostLanguageRu
 		}
 	}
 
+	/**
+	 * Validates a language runtime session.
+	 *
+	 * @param metadata The metadata for the language runtime.
+	 * @param sessionId The session ID to validate.
+	 */
+	async $validateLanguageRuntimeSession(metadata: ILanguageRuntimeMetadata,
+		sessionId: string): Promise<boolean> {
+
+		// Find the runtime manager that should be used for this session
+		const m = await this.runtimeManagerForRuntime(metadata, true);
+		if (m) {
+			if (m.manager.validateSession) {
+				// The runtime manager has a validateSession function; use it to
+				// validate the session
+				const result = await m.manager.validateSession(sessionId);
+				return result;
+			} else {
+				// Just consider the session to be invalid
+				return false;
+			}
+		} else {
+			// We can't validate this session, and probably shouldn't use it.
+			throw new Error(
+				`No manager available for language ID '${metadata.languageId}' ` +
+				`(expected from extension ${metadata.extensionId.value})`);
+		}
+	}
+
 	/**
 	 * Restores a language runtime session.
 	 *
diff --git a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts
index a556393b5b8..7464e33bd47 100644
--- a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts
+++ b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts
@@ -302,9 +302,18 @@ export enum LanguageRuntimeStartupBehavior {
  * An enumeration of possible locations for runtime sessions.
  */
 export enum LanguageRuntimeSessionLocation {
+	/**
+	 * The runtime session is persistent on the machine; it should be
+	 * restored when the workspace is re-opened, and may persist across
+	 * Positron sessions.
+	 */
+	Machine = 'machine',
+
 	/**
 	 * The runtime session is located in the current workspace (usually a
-	 * terminal); it should be restored when the workspace is re-opened.
+	 * terminal); it should be restored when the workspace is re-opened in
+	 * the same Positron session (e.g. during a browser reload or
+	 * reconnect)
 	 */
 	Workspace = 'workspace',
 
diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimeService.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimeService.ts
index 7a221fe36c7..72a1b310ec5 100644
--- a/src/vs/workbench/services/languageRuntime/common/languageRuntimeService.ts
+++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimeService.ts
@@ -491,9 +491,18 @@ export enum LanguageRuntimeMessageType {
  * An enumeration of possible locations for runtime sessions.
  */
 export enum LanguageRuntimeSessionLocation {
+	/**
+	 * The runtime session is persistent on the machine; it should be
+	 * restored when the workspace is re-opened, and may persist across
+	 * Positron sessions.
+	 */
+	Machine = 'machine',
+
 	/**
 	 * The runtime session is located in the current workspace (usually a
-	 * terminal); it should be restored when the workspace is re-opened.
+	 * terminal); it should be restored when the workspace is re-opened in
+	 * the same Positron session (e.g. during a browser reload or
+	 * reconnect)
 	 */
 	Workspace = 'workspace',
 
diff --git a/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts b/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts
index 338232b1092..4faca9e6440 100644
--- a/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts
+++ b/src/vs/workbench/services/runtimeSession/common/runtimeSession.ts
@@ -478,6 +478,31 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession
 		return this.doCreateRuntimeSession(languageRuntime, sessionName, sessionMode, source, startMode, notebookUri);
 	}
 
+	/**
+	 * Validates that a runtime session can be restored.
+	 *
+	 * @param runtimeMetadata
+	 * @param sessionId
+	 */
+	async validateRuntimeSession(
+		runtimeMetadata: ILanguageRuntimeMetadata,
+		sessionId: string): Promise<boolean> {
+
+		// Get the runtime's manager.
+		let sessionManager: ILanguageRuntimeSessionManager;
+		try {
+			sessionManager = await this.getManagerForRuntime(runtimeMetadata);
+		} catch (err) {
+			// This shouldn't happen, but could in unusual circumstances, e.g.
+			// the extension that supplies the runtime was uninstalled and this
+			// is a stale session that it owned the last time we were running.
+			this._logService.error(`Error getting manager for runtime ${formatLanguageRuntimeMetadata(runtimeMetadata)}: ${err}`);
+			// Treat the session as invalid if we can't get the manager.
+			return false;
+		}
+
+		return sessionManager.validateSession(runtimeMetadata, sessionId);
+	}
 
 	/**
 	 * Restores (reconnects to) a runtime session that was previously started.
diff --git a/src/vs/workbench/services/runtimeSession/common/runtimeSessionService.ts b/src/vs/workbench/services/runtimeSession/common/runtimeSessionService.ts
index ea719c98143..ce5d4c159cb 100644
--- a/src/vs/workbench/services/runtimeSession/common/runtimeSessionService.ts
+++ b/src/vs/workbench/services/runtimeSession/common/runtimeSessionService.ts
@@ -233,6 +233,11 @@ export interface ILanguageRuntimeSessionManager {
 		sessionMetadata: IRuntimeSessionMetadata):
 		Promise<ILanguageRuntimeSession>;
 
+	/**
+	 * Validates an existing (persisted) session.
+	 */
+	validateSession(runtimeMetadata: ILanguageRuntimeMetadata, sessionId: string): Promise<boolean>;
+
 	/**
 	 * Restore (reconnect to) an existing session.
 	 *
@@ -350,6 +355,16 @@ export interface IRuntimeSessionService {
 		notebookUri: URI | undefined,
 		source: string): Promise<string>;
 
+	/**
+	 * Validates a persisted runtime session before reconnecting to it.
+	 *
+	 * @param runtimeMetadata The metadata of the runtime.
+	 * @param sesionId The ID of the session to validate.
+	 */
+	validateRuntimeSession(
+		runtimeMetadata: ILanguageRuntimeMetadata,
+		sessionId: string): Promise<boolean>;
+
 	/**
 	 * Restores (reconnects to) a runtime session that was previously started.
 	 *
diff --git a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
index 34efeb8b899..0c7cbe6a4f0 100644
--- a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
+++ b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts
@@ -1,5 +1,5 @@
 /*---------------------------------------------------------------------------------------------
- *  Copyright (C) 2024 Posit Software, PBC. All rights reserved.
+ *  Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved.
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
@@ -48,7 +48,7 @@ interface SerializedSessionMetadata {
  * Amended with the workspace ID to allow for multiple workspaces to store their
  * sessions separately.
  */
-const PERSISTENT_WORKSPACE_SESSIONS_PREFIX = 'positron.workspaceSessionList';
+const PERSISTENT_WORKSPACE_SESSIONS = 'positron.workspaceSessionList';
 
 const languageRuntimeExtPoint =
 	ExtensionsRegistry.registerExtensionPoint<ILanguageRuntimeProviderMetadata[]>({
@@ -772,17 +772,28 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 		// open, and attempt to reconnect to them.
 		let storedSessions: Array<SerializedSessionMetadata> = new Array();
 		try {
-			const sessions = await this._ephemeralStateService.getItem<Array<SerializedSessionMetadata>>(this.getPersistentWorkspaceSessionsKey());
+			const sessions = await this._ephemeralStateService.getItem<Array<SerializedSessionMetadata>>(this.getEphemeralWorkspaceSessionsKey());
 			if (sessions) {
 				storedSessions = sessions;
 			}
 		} catch (err) {
-			this._logService.warn(`Can't read workspace sessions from ${this.getPersistentWorkspaceSessionsKey()}: ${err}. No sessions will be restored.`);
+			this._logService.warn(`Can't read workspace sessions from ${this.getEphemeralWorkspaceSessionsKey()}: ${err}. No sessions will be restored.`);
 		}
 
 		if (!storedSessions) {
 			this._logService.debug(`[Runtime startup] No sessions to resume found in ephemeral storage.`);
-			return;
+		}
+
+		// Next, check for any sessions persisted in the workspace storage.
+		const sessions = this._storageService.get(PERSISTENT_WORKSPACE_SESSIONS,
+			StorageScope.WORKSPACE);
+		if (sessions) {
+			try {
+				const stored = JSON.parse(sessions) as Array<SerializedSessionMetadata>;
+				storedSessions.push(...stored);
+			} catch (err) {
+				this._logService.error(`Error parsing persisted workspace sessions: ${err} (sessions: '${sessions}')`);
+			}
 		}
 
 		try {
@@ -814,6 +825,67 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 	 */
 	private async restoreWorkspaceSessions(sessions: SerializedSessionMetadata[]) {
 		this._startupPhase.set(RuntimeStartupPhase.Reconnecting, undefined);
+
+		// Activate any extensions needed for the sessions that are persistent on the machine.
+		const activatedExtensions: Array<ExtensionIdentifier> = [];
+		await Promise.all(sessions.filter(async session =>
+			session.runtimeMetadata.sessionLocation === LanguageRuntimeSessionLocation.Machine
+		).map(async session => {
+			// If we haven't already activated the extension, activate it now.
+			// We need the extension to be active so that we can ask it to
+			// validate the session before connecting to it.
+			if (activatedExtensions.indexOf(session.runtimeMetadata.extensionId) === -1) {
+				this._logService.debug(`[Runtime startup] Activating extension ` +
+					`${session.runtimeMetadata.extensionId.value} for persisted session ` +
+					`${session.metadata.sessionName} (${session.metadata.sessionId})`);
+				activatedExtensions.push(session.runtimeMetadata.extensionId);
+				return this._extensionService.activateById(session.runtimeMetadata.extensionId,
+					{
+						extensionId: session.runtimeMetadata.extensionId,
+						activationEvent: `onLanguageRuntime:${session.runtimeMetadata.languageId}`,
+						startup: false
+					});
+			}
+		}));
+
+		// Before reconnecting, validate any sessions that need it.
+		const validSessions = await Promise.all(sessions.map(async session => {
+			if (session.runtimeMetadata.sessionLocation === LanguageRuntimeSessionLocation.Machine) {
+				// If the session is persistent on the machine, we need to
+				// check to see if it is still valid (i.e. still running)
+				// before reconnecting.
+				this._logService.debug(`[Runtime startup] Checking to see if persisted session ` +
+					`${session.metadata.sessionName} (${session.metadata.sessionId}) is still valid.`);
+				try {
+					// Ask the runtime session service to validate the session.
+					// This call will eventually be proxied through to the
+					// extension that provides the runtime.
+					const valid = await this._runtimeSessionService.validateRuntimeSession(
+						session.runtimeMetadata,
+						session.metadata.sessionId);
+
+					this._logService.debug(
+						`[Runtime startup] Session ` +
+						`${session.metadata.sessionName} (${session.metadata.sessionId}) valid = ${valid}`);
+					return valid;
+				} catch (err) {
+					// This is a non-fatal error since we can just avoid reconnecting
+					// to the session.
+					this._logService.error(
+						`Error validating persisted session ` +
+						`${session.metadata.sessionName} (${session.metadata.sessionId}): ${err}`);
+					return false;
+				}
+			}
+
+			// Sessions stored in other locations are always valid.
+			return true;
+		}));
+
+		// Remove all the sessions that are no longer valid.
+		sessions = sessions.filter((_, i) => validSessions[i]);
+
+		// Reconnect to the remaining sessions.
 		this._logService.debug(`Reconnecting to sessions: ` +
 			sessions.map(session => session.metadata.sessionName).join(', '));
 
@@ -838,11 +910,13 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 	}
 
 	/**
-	 * Clear the set of workspace sessions in the workspace storage.
+	 * Clear the set of workspace sessions in the ephemeral workspace storage.
 	 */
 	private async clearWorkspaceSessions(): Promise<boolean> {
-		// Clear the sessions.
-		await this._ephemeralStateService.removeItem(this.getPersistentWorkspaceSessionsKey());
+		// Clear the sessions. Note that we only ever clear the sessions from
+		// the ephemeral storage, since the persisted sessions are meant to be
+		// restored later.
+		await this._ephemeralStateService.removeItem(this.getEphemeralWorkspaceSessionsKey());
 
 		// Always return false (don't veto shutdown)
 		return false;
@@ -855,13 +929,12 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 	 * process.
 	 */
 	private async saveWorkspaceSessions(): Promise<boolean> {
-		// Derive the set of sessions that are currently active and workspace scoped.
-		const workspaceSessions = this._runtimeSessionService.activeSessions
+		// Derive the set of sessions that are currently active
+		const activeSessions = this._runtimeSessionService.activeSessions
 			.filter(session =>
 				session.getRuntimeState() !== RuntimeState.Uninitialized &&
 				session.getRuntimeState() !== RuntimeState.Initializing &&
-				session.getRuntimeState() !== RuntimeState.Exited &&
-				session.runtimeMetadata.sessionLocation === LanguageRuntimeSessionLocation.Workspace
+				session.getRuntimeState() !== RuntimeState.Exited
 			)
 			.map(session => {
 				const metadata: SerializedSessionMetadata = {
@@ -873,13 +946,25 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 			});
 
 		// Diagnostic logs: what are we saving?
-		this._logService.trace(`Saving workspace sessions: ${workspaceSessions.map(session =>
-			`${session.metadata.sessionName} (${session.metadata.sessionId})`).join(', ')}`);
-
-		// Save the sessions to the workspace storage.
-		this._logService.debug(`[Runtime startup] Saving workspace sessions (${workspaceSessions.length})`);
-		this._ephemeralStateService.setItem(this.getPersistentWorkspaceSessionsKey(),
+		this._logService.trace(`Saving workspace sessions: ${activeSessions.map(session =>
+			`${session.metadata.sessionName} (${session.metadata.sessionId}, ${session.runtimeMetadata.sessionLocation})`).join(', ')}`);
+
+		// Save the ephemeral sessions to the workspace storage.
+		const workspaceSessions = activeSessions.filter(session =>
+			session.runtimeMetadata.sessionLocation === LanguageRuntimeSessionLocation.Workspace);
+		this._logService.debug(`[Runtime startup] Saving ephemeral workspace sessions (${workspaceSessions.length})`);
+		this._ephemeralStateService.setItem(this.getEphemeralWorkspaceSessionsKey(),
 			workspaceSessions);
+
+		// Save the persisted sessions to the workspace storage.
+		const machineSessions = activeSessions.filter(session =>
+			session.runtimeMetadata.sessionLocation === LanguageRuntimeSessionLocation.Machine);
+		this._logService.debug(`[Runtime startup] Saving machine-persisted workspace sessions (${machineSessions.length})`);
+		this._storageService.store(
+			PERSISTENT_WORKSPACE_SESSIONS,
+			JSON.stringify(machineSessions),
+			StorageScope.WORKSPACE, StorageTarget.MACHINE);
+
 		return false;
 	}
 
@@ -945,8 +1030,14 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup
 
 	}
 
-	private getPersistentWorkspaceSessionsKey(): string {
-		return `${PERSISTENT_WORKSPACE_SESSIONS_PREFIX}.${this._workspaceContextService.getWorkspace().id}`;
+	/**
+	 * Gets the storage key used to store the set of workspace sessions in
+	 * ephemeral storage.
+	 */
+	private getEphemeralWorkspaceSessionsKey(): string {
+		// We include the workspace ID in the key since ephemeral storage can
+		// be shared among workspaces in e.g. Positron Server.
+		return `${PERSISTENT_WORKSPACE_SESSIONS}.${this._workspaceContextService.getWorkspace().id}`;
 	}
 }
 
diff --git a/src/vs/workbench/test/common/positronWorkbenchTestServices.ts b/src/vs/workbench/test/common/positronWorkbenchTestServices.ts
index 5d6c31c1be5..ee350104c34 100644
--- a/src/vs/workbench/test/common/positronWorkbenchTestServices.ts
+++ b/src/vs/workbench/test/common/positronWorkbenchTestServices.ts
@@ -140,6 +140,10 @@ export class TestRuntimeSessionManager implements ILanguageRuntimeSessionManager
 		return metadata;
 	}
 
+	async validateSession(runtimeMetadata: ILanguageRuntimeMetadata, sessionId: string): Promise<boolean> {
+		return true;
+	}
+
 	setValidateMetadata(handler: (metadata: ILanguageRuntimeMetadata) => Promise<ILanguageRuntimeMetadata>): void {
 		this._validateMetadata = handler;
 	}