diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx
index 7d22b2e27eac8..63e40e0141321 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx
@@ -95,7 +95,21 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({
}));
vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null }));
vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children }));
-vi.mock("../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null }));
+vi.mock("../../components/ui/ListChoiceRow", () => ({
+ ListChoiceRow: ({
+ label,
+ disabled,
+ onPress,
+ }: {
+ label: string;
+ disabled?: boolean;
+ onPress: () => void;
+ }) => (
+
+ ),
+}));
vi.mock("./components/SettingsSwitchRow", () => ({
SettingsSwitchRow: ({
label,
@@ -158,7 +172,11 @@ function environment(environmentId: string, label: string) {
sidebarAutoSettleOnMerge: false,
},
environment: {
- capabilities: { projectSettingsOverrides: true, threadAutoSettlement: true },
+ capabilities: {
+ projectSettingsOverrides: true,
+ threadAutoSettlement: true,
+ providerSessionIdleTimeout: true,
+ },
},
},
};
@@ -222,6 +240,25 @@ describe("SettingsServerControlsRouteScreen", () => {
await act(async () => pending.resolve(AsyncResult.success(undefined)));
});
+ it.each([false, true])(
+ "does not send idle timeout changes to an excluded target with idle capability %s",
+ async (idleCapability) => {
+ const remote = environment("remote", "Remote");
+ remote.connection.phase = "disconnected";
+ remote.serverConfig.environment.capabilities.providerSessionIdleTimeout = idleCapability;
+ mocks.targets = [environment("local", "Local"), remote];
+ act(() => root.render());
+ const preset = [...container.querySelectorAll("button")].find(
+ (button) => button.textContent === "5 minutes",
+ )!;
+ expect(preset).toBeDefined();
+ await act(async () => preset.click());
+ expect(mocks.updateSettings.mock.calls).toEqual([
+ [{ environmentId: "local", input: { patch: { providerSessionIdleTimeoutMinutes: 5 } } }],
+ ]);
+ },
+ );
+
it("disables auto-settle when a selected environment cannot save settings", () => {
const remote = environment("remote", "Remote");
// Auto-settle rows skip a server without auto-settlement, but the page still saves to it.
diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index 5026a90e13b9a..e02aaab0cbb19 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -149,7 +149,9 @@ function planServerSettingsWrites(
const capable = targets.filter((target) => supportsSharedSettingsSync(target.environment));
const others = targets.filter((target) => !supportsSharedSettingsSync(target.environment));
const othersPatch = Object.fromEntries(
- Object.entries(patch).filter(([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key)),
+ Object.entries(patch).filter(
+ ([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key) && key !== "providerSessionIdleTimeoutMinutes",
+ ),
) as ServerSettingsPatch;
return [
...planMobileScopedSettingsPatch(capable, projectSelected, patch),
diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
index 41600619b3a47..0657eacd3388c 100644
--- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
+++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
@@ -13,7 +13,7 @@ import type { ComponentProps } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
+import { DEFAULT_SERVER_SETTINGS, PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS } from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/ui/AppText";
import { useEnvironments } from "../../state/environments";
@@ -21,6 +21,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsSection } from "./components/SettingsSection";
+import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsScreen } from "./components/SettingsScreen";
import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
@@ -101,6 +102,7 @@ export function AutoSettleSettingsRows(props: {
patch: Partial & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
+ providerSessionIdleTimeoutMinutes?: number | null;
},
) => props.write(patch);
@@ -145,6 +147,31 @@ export function AutoSettleSettingsRows(props: {
/>
) : null}
+ {!projectSelected &&
+ displayTargets.every(
+ (target) =>
+ target.environment.serverConfig.environment.capabilities.providerSessionIdleTimeout ===
+ true,
+ ) ? (
+
+
+ Free memory while idle. Conversations resume when you send a message. Active turns and
+ background work delay release.
+
+ {PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS.map((option, index) => (
+ 0}
+ disabled={disabled}
+ selected={displayTargets.every(
+ (target) => target.settings.providerSessionIdleTimeoutMinutes === option.value,
+ )}
+ onPress={() => writeToAll({ providerSessionIdleTimeoutMinutes: option.value })}
+ />
+ ))}
+
+ ) : null}
;
- readonly idleTimeoutMs: number;
+ readonly idleTimeoutMs?: number;
readonly maxIdlePinMs?: number;
readonly failEventStream?: boolean;
readonly normalizeThreadLoadModelSelection?: (selection: ModelSelection) => ModelSelection;
@@ -727,7 +729,7 @@ function layerTest(input: {
layerConfiguredMcpRegistry,
McpProviderSessions.layer,
ProviderSessionManager.layerWithOptions({
- idleTimeoutMs: input.idleTimeoutMs,
+ ...(input.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: input.idleTimeoutMs }),
...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }),
}).pipe(
Layer.provide(
@@ -5641,3 +5643,269 @@ it.effect("ProviderSessionManagerV2 gives a delegated child its project's device
assert.isTrue(child?.capabilities?.has("device"));
}),
);
+
+it.effect(
+ "ProviderSessionManagerV2 falls back to 30 minutes when idle settings cannot be read",
+ () =>
+ Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ const settings = yield* ServerSettings.ServerSettingsService.pipe(
+ Effect.provide(ServerSettings.layerTest()),
+ );
+ const settingsLayer = Layer.succeed(ServerSettings.ServerSettingsService, {
+ ...settings,
+ getSettings: Effect.fail(
+ new ServerSettingsError({
+ settingsPath: "",
+ operation: "read-file",
+ cause: new Error("Settings unavailable"),
+ }),
+ ),
+ });
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("unreadable-idle-settings");
+ const providerSessionId = yield* idAllocator.allocate.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ threadId,
+ });
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy });
+ yield* TestClock.adjust("29 minutes");
+ assert.equal((yield* Ref.get(state)).closeCount, 0);
+ yield* TestClock.adjust("1 minute");
+ assert.isTrue(Option.isNone(yield* manager.get(providerSessionId)));
+ assert.equal((yield* Ref.get(state)).closeCount, 1);
+ }).pipe(Effect.provide(layerTest({ state, serverSettingsLayer: settingsLayer })));
+ }),
+);
+
+it.effect.each([
+ { timeoutMinutes: null, label: "disabled" },
+ { timeoutMinutes: 1, label: "one minute" },
+] as const)(
+ "ProviderSessionManagerV2 applies configured idle timeout $label",
+ ({ timeoutMinutes }) =>
+ Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("configured-idle-timeout");
+ const providerSessionId = yield* idAllocator.allocate.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ threadId,
+ });
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ const runtime = yield* manager.open({
+ threadId,
+ providerSessionId,
+ modelSelection,
+ runtimePolicy,
+ });
+ const providerThread = makeProviderThread({
+ idAllocator,
+ threadId,
+ providerSessionId,
+ now,
+ nativeThreadId: "saved-conversation",
+ });
+ yield* runtime.resumeThread({ providerThread, threadId, modelSelection, runtimePolicy });
+ yield* TestClock.adjust("59 seconds");
+ assert.equal((yield* Ref.get(state)).closeCount, 0);
+ if (timeoutMinutes === null) {
+ yield* TestClock.adjust("2 hours");
+ assert.equal((yield* Ref.get(state)).closeCount, 0);
+ assert.isTrue(Option.isSome(yield* manager.get(providerSessionId)));
+ } else {
+ const stopped = yield* eventSink
+ .stream({
+ threadId,
+ afterSequence: yield* eventSink.latestSequence({ threadId }),
+ eventType: "provider-session.updated",
+ })
+ .pipe(
+ Stream.filter(
+ (stored) =>
+ stored.event.type === "provider-session.updated" &&
+ stored.event.payload.status === "stopped",
+ ),
+ Stream.runHead,
+ Effect.forkScoped,
+ );
+ yield* TestClock.adjust("1 second");
+ yield* Fiber.join(stopped);
+ assert.equal((yield* Ref.get(state)).closeCount, 1);
+ const reopened = yield* manager.open({
+ threadId,
+ providerSessionId,
+ modelSelection,
+ runtimePolicy,
+ });
+ const resumed = yield* reopened.resumeThread({
+ providerThread,
+ threadId,
+ modelSelection,
+ runtimePolicy,
+ });
+ assert.deepEqual(resumed.nativeThreadRef, providerThread.nativeThreadRef);
+ assert.equal((yield* Ref.get(state)).openCount, 2);
+ assert.equal((yield* Ref.get(state)).resumeCount, 2);
+ }
+ }).pipe(
+ Effect.provide(
+ layerTest({
+ state,
+ serverSettingsLayer: ServerSettings.layerTest({
+ providerSessionIdleTimeoutMinutes: timeoutMinutes,
+ }),
+ }),
+ ),
+ );
+ }),
+);
+
+it.effect(
+ "ProviderSessionManagerV2 updates pending idle waits without resetting elapsed time",
+ () =>
+ Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ const settings = yield* ServerSettings.ServerSettingsService.pipe(
+ Effect.provide(ServerSettings.layerTest({ providerSessionIdleTimeoutMinutes: 1 })),
+ );
+ const changes = yield* PubSub.unbounded();
+ const settingsLayer = Layer.succeed(ServerSettings.ServerSettingsService, {
+ ...settings,
+ subscribeChanges: PubSub.subscribe(changes).pipe(Effect.map(Stream.fromSubscription)),
+ });
+ const updateTimeout = (value: number | null) =>
+ settings
+ .updateSettings({ providerSessionIdleTimeoutMinutes: value })
+ .pipe(Effect.flatMap((updated) => PubSub.publish(changes, updated)));
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("changed-idle-timeout");
+ const providerSessionId = yield* idAllocator.allocate.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ threadId,
+ });
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy });
+ yield* TestClock.adjust("30 seconds");
+ yield* updateTimeout(5);
+ yield* TestClock.adjust("1 minute");
+ assert.equal((yield* Ref.get(state)).closeCount, 0);
+ yield* updateTimeout(null);
+ yield* TestClock.adjust("2 hours");
+ assert.equal((yield* Ref.get(state)).closeCount, 0);
+ const stopped = yield* eventSink
+ .stream({
+ threadId,
+ afterSequence: yield* eventSink.latestSequence({ threadId }),
+ eventType: "provider-session.updated",
+ })
+ .pipe(
+ Stream.filter(
+ (stored) =>
+ stored.event.type === "provider-session.updated" &&
+ stored.event.payload.status === "stopped",
+ ),
+ Stream.runHead,
+ Effect.forkScoped,
+ );
+ yield* updateTimeout(1);
+ yield* TestClock.adjust("0 millis");
+ yield* Fiber.join(stopped);
+ assert.equal((yield* Ref.get(state)).closeCount, 1);
+ }).pipe(Effect.provide(layerTest({ state, serverSettingsLayer: settingsLayer })));
+ }),
+);
+
+it.effect("ProviderSessionManagerV2 uses the configured timeout for shared thread unloading", () =>
+ Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ const unloading = yield* Deferred.make();
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("configured-shared-idle-timeout");
+ const providerSessionId = idAllocator.derive.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ });
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ const runtime = yield* manager.open({
+ threadId,
+ providerSessionId,
+ modelSelection,
+ runtimePolicy,
+ });
+ const providerThread = makeProviderThread({
+ idAllocator,
+ threadId,
+ providerSessionId,
+ now,
+ nativeThreadId: "shared-saved-conversation",
+ });
+ const resume = runtime.resumeThread({
+ providerThread,
+ threadId,
+ modelSelection,
+ runtimePolicy,
+ });
+ yield* resume;
+ const subscription = yield* runtime.subscribeEvents!;
+ const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId));
+ assert.isDefined(queue);
+ yield* Queue.offer(queue!, {
+ type: "turn.terminal",
+ driver: CODEX_DRIVER,
+ providerThreadId: providerThread.id,
+ providerTurnId: idAllocator.derive.providerTurn({
+ driver: CODEX_DRIVER,
+ nativeTurnId: "completed-shared-turn",
+ }),
+ runOrdinal: 1,
+ status: "completed",
+ failure: null,
+ threadDisposition: "reusable",
+ });
+ yield* subscription.events.pipe(Stream.runHead);
+ yield* TestClock.adjust("59 seconds");
+ assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []);
+ yield* TestClock.adjust("1 second");
+ yield* Deferred.await(unloading);
+ yield* resume;
+ const observed = yield* Ref.get(state);
+ assert.equal(observed.closeCount, 0);
+ assert.deepEqual(observed.unloadedNativeThreadIds, ["shared-saved-conversation"]);
+ assert.equal(observed.resumeCount, 2);
+ }).pipe(
+ Effect.provide(
+ layerTest({
+ state,
+ hasPendingBackgroundWork: Effect.succeed(true),
+ beforeUnload: Deferred.succeed(unloading, undefined).pipe(Effect.asVoid),
+ serverSettingsLayer: ServerSettings.layerTest({ providerSessionIdleTimeoutMinutes: 1 }),
+ }),
+ ),
+ );
+ }),
+);
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
index 2e9adb9c8a54c..ce43cfaa97171 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
@@ -524,7 +524,48 @@ export const layerWithOptions = (
readonly providerSessionId: ProviderSessionId;
readonly threadId: ThreadId;
}) => `${input.providerSessionId}\u0000${input.threadId}`;
- const idleTimeoutMs = Math.max(1, options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS);
+ // Subscribe before reading so a settings change cannot get lost between
+ // the snapshot and the wait. Keep elapsed idle time when changing policy.
+ const waitForIdleTimeout = Effect.fnUntraced(function* () {
+ if (options.idleTimeoutMs !== undefined) {
+ return yield* Effect.sleep(Duration.millis(Math.max(1, options.idleTimeoutMs)));
+ }
+ if (Option.isNone(serverSettings)) {
+ return yield* Effect.sleep(Duration.millis(DEFAULT_IDLE_TIMEOUT_MS));
+ }
+ const settingsService = serverSettings.value;
+ return yield* Effect.scoped(
+ Effect.gen(function* () {
+ const changes = yield* settingsService.subscribeChanges;
+ const startedAt = yield* Clock.currentTimeMillis;
+ while (true) {
+ const minutes = yield* settingsService.getSettings.pipe(
+ Effect.map((settings) => settings.providerSessionIdleTimeoutMinutes),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("orchestration-v2.driver-session.idle-settings-unavailable", {
+ cause,
+ }).pipe(Effect.as(DEFAULT_IDLE_TIMEOUT_MS / 60_000)),
+ ),
+ );
+ const elapsed = (yield* Clock.currentTimeMillis) - startedAt;
+ const expired =
+ minutes === null
+ ? Effect.never
+ : Effect.sleep(Duration.millis(Math.max(0, minutes * 60_000 - elapsed)));
+ const outcome = yield* Effect.raceFirst(
+ expired.pipe(Effect.as("expired" as const)),
+ changes.pipe(
+ Stream.runHead,
+ Effect.flatMap((next) =>
+ Option.isNone(next) ? Effect.never : Effect.succeed("changed" as const),
+ ),
+ ),
+ );
+ if (outcome === "expired") return;
+ }
+ }),
+ );
+ });
const maxIdlePinMs = Math.max(0, options.maxIdlePinMs ?? DEFAULT_MAX_IDLE_PIN_MS);
interface PreparedMcpCredential {
readonly mcpCredentialId: string | undefined;
@@ -1516,7 +1557,7 @@ export const layerWithOptions = (
pinnedForMs: now - pinnedSinceMs,
});
// Recheck on this fiber; scheduleIdleReleaseInternal would interrupt it and self-deadlock.
- yield* Effect.sleep(Duration.millis(idleTimeoutMs));
+ yield* waitForIdleTimeout();
return yield* releaseIfStillIdle(input);
}
yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", {
@@ -1585,7 +1626,7 @@ export const layerWithOptions = (
yield* cancelIdleFiber(entry.idleFiber);
const generation = entry.idleGeneration + 1;
- const idleFiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe(
+ const idleFiber = yield* waitForIdleTimeout().pipe(
Effect.andThen(releaseIfStillIdle({ providerSessionId, generation })),
Effect.forkIn(layerScope),
);
@@ -1938,7 +1979,7 @@ export const layerWithOptions = (
if (tracked === undefined) return;
const [threadId, pending] = tracked;
const generation = pending.generation + 1;
- const fiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe(
+ const fiber = yield* waitForIdleTimeout().pipe(
Effect.andThen(unloadIdleThread({ providerSessionId, threadId, generation })),
Effect.forkIn(layerScope),
);
@@ -2085,7 +2126,7 @@ export const layerWithOptions = (
);
// Wait outside the lock so the next attach is not blocked.
if (outcome === "deferred" && input.archiveOnly !== true) {
- yield* Effect.sleep(Duration.millis(idleTimeoutMs));
+ yield* waitForIdleTimeout();
return yield* unloadIdleThread(input);
}
},
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 633e18a567b2f..a677920d6ccdd 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -8,6 +8,7 @@ import { useRef, useState } from "react";
import {
type DesktopUpdateChannel,
IDLE_COMPACTION_MODES,
+ PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS,
globalIdleCompaction,
managedUpdatesDenial,
managedUpdatesMessage,
@@ -1726,6 +1727,17 @@ export function ThreadsSettingsPanel() {
const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
const environmentId = environment?.environmentId ?? null;
const hasServerTargets = connectedEnvironments.length > 0;
+ const mixedIdleTimeout = useScopedSettingsMixed(["providerSessionIdleTimeoutMinutes"]);
+ const supportsIdleTimeout =
+ connectedEnvironments.length > 0 &&
+ connectedEnvironments.every(
+ (environment) =>
+ environment.serverConfig?.environment.capabilities.providerSessionIdleTimeout === true,
+ );
+ const idleTimeoutValue =
+ settings.providerSessionIdleTimeoutMinutes === null
+ ? "never"
+ : String(settings.providerSessionIdleTimeoutMinutes);
const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]);
const providersByEnvironment = useEnvironmentProviders();
const serverProviders =
@@ -1840,6 +1852,65 @@ export function ThreadsSettingsPanel() {
+
+
+ updateSettings({
+ providerSessionIdleTimeoutMinutes:
+ DEFAULT_UNIFIED_SETTINGS.providerSessionIdleTimeoutMinutes,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
{!isProjectScope ? (
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 7d7939e7f21f9..0b5fa45aeb48b 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -645,6 +645,13 @@ export const SETTINGS_SEARCH_ITEMS = [
requiresThreadAutoSettlement: true,
scope: "project-defaults",
},
+ {
+ id: "release-idle-sessions",
+ title: "Release idle agents",
+ to: "/settings/threads",
+ scope: "environment",
+ searchTerms: ["memory RAM process idle timeout suspend resume inactivity agents"],
+ },
{
id: "compact-idle-threads",
title: "Compact idle threads",
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index 899640ad23590..65dcf51fbdd80 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -285,3 +285,14 @@ On mobile, use **Settings → Source control** to change selected environment de
T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files,
or local commits. It skips checkouts on another branch or without an upstream. If a checkout has
local work, resolve it yourself before automatic pulls can resume.
+
+## Idle agents and memory
+
+In web or desktop **Settings → Threads → Release idle agents**, choose how long an idle
+agent stays in memory, or **Never** to keep it loaded. On mobile, use **Settings → Threads**. This is an environment setting because agents can share a process across projects.
+The default remains 30 minutes. Changes also apply to agents already waiting while idle.
+
+Release keeps the saved conversation; sending another message resumes it automatically.
+Active turns and provider-reported background work delay release. This does not compact
+conversation history or extend the provider's prompt cache. Resuming can take longer and use
+more input tokens after that cache expires. Configure Claude idle compaction separately.
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 603b6c6cc862d..683f763b22c92 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -103,6 +103,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean),
threadSettlement: Schema.optionalKey(Schema.Boolean),
threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
+ providerSessionIdleTimeout: Schema.optionalKey(Schema.Boolean),
storageCleanup: Schema.optionalKey(Schema.Boolean),
storageCleanupRun: Schema.optionalKey(Schema.Boolean),
databaseStorage: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 1db913e38ba3b..0dd6c176b87cb 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -43,6 +43,29 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings);
const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch);
const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
+describe("provider session idle timeout", () => {
+ it("preserves the existing 30 minute default and accepts disabling or shortening it", () => {
+ expect(decodeServerSettings({}).providerSessionIdleTimeoutMinutes).toBe(30);
+ for (const value of [null, 1, 5, 30, 1440]) {
+ expect(decodeServerSettingsPatch({ providerSessionIdleTimeoutMinutes: value })).toEqual({
+ providerSessionIdleTimeoutMinutes: value,
+ });
+ expect(
+ decodeServerSettings({ providerSessionIdleTimeoutMinutes: value })
+ .providerSessionIdleTimeoutMinutes,
+ ).toBe(value);
+ }
+ });
+
+ it("rejects invalid timeouts rather than overflowing timers", () => {
+ for (const value of [0, -1, 1.5, 1441, Infinity, "5"]) {
+ expect(() =>
+ decodeServerSettingsPatch({ providerSessionIdleTimeoutMinutes: value }),
+ ).toThrow();
+ }
+ });
+});
+
it("drops theme defaults from a client's settings patch", () => {
expect(
decodeClientServerSettingsPatch({
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 7bbb03595d4bb..7dc0c7a057bb2 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -97,6 +97,19 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check(
);
export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 7;
+export const PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS = [
+ { value: null, label: "Never" },
+ { value: 1, label: "1 minute" },
+ { value: 5, label: "5 minutes" },
+ { value: 10, label: "10 minutes" },
+ { value: 30, label: "30 minutes" },
+ { value: 60, label: "1 hour" },
+] as const;
+
+/** Null keeps idle runtimes loaded; minutes bound native timer delays. */
+const ProviderSessionIdleTimeoutMinutes = Schema.NullOr(
+ Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1440 })),
+);
export const MAX_SNOOZE_PRESET_MINUTES = 30 * 24 * 60;
export const MAX_SNOOZE_PRESETS = 8;
@@ -1451,6 +1464,9 @@ export const ServerSettings = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed(false)),
),
idleCompaction: Schema.optionalKey(IdleCompactionMode),
+ providerSessionIdleTimeoutMinutes: ProviderSessionIdleTimeoutMinutes.pipe(
+ Schema.withDecodingDefault(Effect.succeed(30)),
+ ),
snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
snoozePresets: SnoozePresetSettings.pipe(
@@ -1897,6 +1913,7 @@ export const ServerSettingsPatch = Schema.Struct({
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads),
idleCompaction: Schema.optionalKey(IdleCompactionMode),
+ providerSessionIdleTimeoutMinutes: Schema.optionalKey(ProviderSessionIdleTimeoutMinutes),
sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean),
sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),