diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md
index 19e53c385..53caaff11 100644
--- a/docs/features/session-persistence.md
+++ b/docs/features/session-persistence.md
@@ -433,14 +433,26 @@ await client.deleteSession("user-123-task-456");
## Automatic Cleanup: Idle Timeout
-The CLI has a built-in 30-minute idle timeout. Sessions without activity are automatically cleaned up:
+By default, sessions have **no idle timeout** and live indefinitely until explicitly disconnected or deleted. You can optionally configure a server-wide idle timeout via `CopilotClientOptions.sessionIdleTimeoutSeconds`:
+
+```typescript
+const client = new CopilotClient({
+ sessionIdleTimeoutSeconds: 30 * 60, // 30 minutes
+});
+```
+
+When a timeout is configured, sessions without activity for that duration are automatically cleaned up. Set to `0` or omit to disable.
+
+> **Note:** This option only applies when the SDK spawns the runtime process. When connecting to an existing server via `cliUrl`, the server's own timeout configuration applies.
```mermaid
flowchart LR
- A["⚡ Last Activity"] --> B["⏳ 25 min
timeout_warning"] --> C["🧹 30 min
destroyed"]
+ A["⚡ Last Activity"] --> B["⏳ ~5 min before
timeout_warning"] --> C["🧹 Timeout
destroyed"]
```
-Listen for idle events to know when work completes:
+Sessions with active work (running commands, background agents) are always protected from idle cleanup, regardless of the timeout setting.
+
+Listen for idle events to react to session inactivity:
```typescript
session.on("session.idle", (event) => {
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index 3a161a391..ae507a3c1 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -1190,6 +1190,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
args.Add("--no-auto-login");
}
+ if (options.SessionIdleTimeoutSeconds is > 0)
+ {
+ args.AddRange(["--session-idle-timeout", options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]);
+ }
+
var (fileName, processArgs) = ResolveCliCommand(cliPath, args);
var startInfo = new ProcessStartInfo
diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs
index e42c34f5d..d84cd835f 100644
--- a/dotnet/src/Types.cs
+++ b/dotnet/src/Types.cs
@@ -69,6 +69,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
UseStdio = other.UseStdio;
OnListModels = other.OnListModels;
SessionFs = other.SessionFs;
+ SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds;
}
///
@@ -165,6 +166,15 @@ public string? GithubToken
///
public TelemetryConfig? Telemetry { get; set; }
+ ///
+ /// Server-wide idle timeout for sessions in seconds.
+ /// Sessions without activity for this duration are automatically cleaned up.
+ /// Set to 0 or leave as to disable (sessions live indefinitely).
+ /// This option is only used when the SDK spawns the CLI process; it is ignored
+ /// when connecting to an external server via .
+ ///
+ public int? SessionIdleTimeoutSeconds { get; set; }
+
///
/// Creates a shallow clone of this instance.
///
diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs
index c62c5bc3f..e8c36776f 100644
--- a/dotnet/test/ClientTests.cs
+++ b/dotnet/test/ClientTests.cs
@@ -216,6 +216,25 @@ public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl()
});
}
+ [Fact]
+ public void Should_Default_SessionIdleTimeoutSeconds_To_Null()
+ {
+ var options = new CopilotClientOptions();
+
+ Assert.Null(options.SessionIdleTimeoutSeconds);
+ }
+
+ [Fact]
+ public void Should_Accept_SessionIdleTimeoutSeconds_Option()
+ {
+ var options = new CopilotClientOptions
+ {
+ SessionIdleTimeoutSeconds = 600
+ };
+
+ Assert.Equal(600, options.SessionIdleTimeoutSeconds);
+ }
+
[Fact]
public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client()
{
diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs
index 5c326dcc4..8ed45b062 100644
--- a/dotnet/test/CloneTests.cs
+++ b/dotnet/test/CloneTests.cs
@@ -26,6 +26,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
Environment = new Dictionary { ["KEY"] = "value" },
GitHubToken = "ghp_test",
UseLoggedInUser = false,
+ SessionIdleTimeoutSeconds = 600,
};
var clone = original.Clone();
@@ -42,6 +43,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
Assert.Equal(original.Environment, clone.Environment);
Assert.Equal(original.GitHubToken, clone.GitHubToken);
Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser);
+ Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds);
}
[Fact]
diff --git a/go/client.go b/go/client.go
index 4eb56e639..0c72e963f 100644
--- a/go/client.go
+++ b/go/client.go
@@ -215,6 +215,10 @@ func NewClient(options *ClientOptions) *Client {
sessionFs := *options.SessionFs
opts.SessionFs = &sessionFs
}
+ if options.Telemetry != nil {
+ opts.Telemetry = options.Telemetry
+ }
+ opts.SessionIdleTimeoutSeconds = options.SessionIdleTimeoutSeconds
}
// Default Env to current environment if not set
@@ -1378,6 +1382,10 @@ func (c *Client) startCLIServer(ctx context.Context) error {
args = append(args, "--no-auto-login")
}
+ if c.options.SessionIdleTimeoutSeconds > 0 {
+ args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds))
+ }
+
// If CLIPath is a .js file, run it with node
// Note we can't rely on the shebang as Windows doesn't support it
command := cliPath
diff --git a/go/client_test.go b/go/client_test.go
index 8840e8269..83e791333 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -391,6 +391,26 @@ func TestClient_EnvOptions(t *testing.T) {
})
}
+func TestClient_SessionIdleTimeoutSeconds(t *testing.T) {
+ t.Run("should store SessionIdleTimeoutSeconds option", func(t *testing.T) {
+ client := NewClient(&ClientOptions{
+ SessionIdleTimeoutSeconds: 600,
+ })
+
+ if client.options.SessionIdleTimeoutSeconds != 600 {
+ t.Errorf("Expected SessionIdleTimeoutSeconds to be 600, got %d", client.options.SessionIdleTimeoutSeconds)
+ }
+ })
+
+ t.Run("should default SessionIdleTimeoutSeconds to zero", func(t *testing.T) {
+ client := NewClient(&ClientOptions{})
+
+ if client.options.SessionIdleTimeoutSeconds != 0 {
+ t.Errorf("Expected SessionIdleTimeoutSeconds to be 0, got %d", client.options.SessionIdleTimeoutSeconds)
+ }
+ })
+}
+
func findCLIPathForTest() string {
abs, _ := filepath.Abs("../nodejs/node_modules/@github/copilot/index.js")
if fileExistsForTest(abs) {
diff --git a/go/types.go b/go/types.go
index e11d21402..14905ec13 100644
--- a/go/types.go
+++ b/go/types.go
@@ -71,6 +71,12 @@ type ClientOptions struct {
// When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated fields
// are mapped to the corresponding environment variables.
Telemetry *TelemetryConfig
+ // SessionIdleTimeoutSeconds configures the server-wide session idle timeout in seconds.
+ // Sessions without activity for this duration are automatically cleaned up.
+ // Set to 0 or leave unset to disable (sessions live indefinitely).
+ // This option is only used when the SDK spawns the CLI process; it is ignored
+ // when connecting to an external server via CLIUrl.
+ SessionIdleTimeoutSeconds int
}
// TelemetryConfig configures OpenTelemetry integration for the Copilot CLI process.
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index a8eba8c37..0ef19038f 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -340,6 +340,7 @@ export class CopilotClient {
// Default useLoggedInUser to false when githubToken is provided, otherwise true
useLoggedInUser: options.useLoggedInUser ?? (options.githubToken ? false : true),
telemetry: options.telemetry,
+ sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
};
}
@@ -1414,6 +1415,16 @@ export class CopilotClient {
args.push("--no-auto-login");
}
+ if (
+ this.options.sessionIdleTimeoutSeconds !== undefined &&
+ this.options.sessionIdleTimeoutSeconds > 0
+ ) {
+ args.push(
+ "--session-idle-timeout",
+ this.options.sessionIdleTimeoutSeconds.toString()
+ );
+ }
+
// Suppress debug/trace output that might pollute stdout
const envWithoutNodeDebug = { ...this.options.env };
delete envWithoutNodeDebug.NODE_DEBUG;
diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts
index 9f6eaf11d..bb4e862b4 100644
--- a/nodejs/src/types.ts
+++ b/nodejs/src/types.ts
@@ -184,6 +184,16 @@ export interface CopilotClientOptions {
* instead of the server's default local filesystem storage.
*/
sessionFs?: SessionFsConfig;
+
+ /**
+ * Server-wide idle timeout for sessions in seconds.
+ * Sessions without activity for this duration are automatically cleaned up.
+ * Set to 0 or omit to disable (sessions live indefinitely).
+ * This option is only used when the SDK spawns the CLI process; it is ignored
+ * when connecting to an external server via {@link cliUrl}.
+ * @default undefined (disabled)
+ */
+ sessionIdleTimeoutSeconds?: number;
}
/**
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index 4ea74b576..23824061c 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -1258,4 +1258,23 @@ describe("CopilotClient", () => {
rpcSpy.mockRestore();
});
});
+
+ describe("sessionIdleTimeoutSeconds", () => {
+ it("should default to 0 when not specified", () => {
+ const client = new CopilotClient({
+ logLevel: "error",
+ });
+
+ expect((client as any).options.sessionIdleTimeoutSeconds).toBe(0);
+ });
+
+ it("should store a custom value", () => {
+ const client = new CopilotClient({
+ sessionIdleTimeoutSeconds: 600,
+ logLevel: "error",
+ });
+
+ expect((client as any).options.sessionIdleTimeoutSeconds).toBe(600);
+ });
+ });
});
diff --git a/python/copilot/client.py b/python/copilot/client.py
index a51940a96..cf89476ed 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -150,6 +150,14 @@ class SubprocessConfig:
session_fs: SessionFsConfig | None = None
"""Connection-level session filesystem provider configuration."""
+ session_idle_timeout_seconds: int | None = None
+ """Server-wide session idle timeout in seconds.
+
+ Sessions without activity for this duration are automatically cleaned up.
+ Set to ``None`` or ``0`` to disable (sessions live indefinitely).
+ This option is only used when the SDK spawns the CLI process.
+ """
+
@dataclass
class ExternalServerConfig:
@@ -2261,6 +2269,9 @@ async def _start_cli_server(self) -> None:
if not cfg.use_logged_in_user:
args.append("--no-auto-login")
+ if cfg.session_idle_timeout_seconds is not None and cfg.session_idle_timeout_seconds > 0:
+ args.extend(["--session-idle-timeout", str(cfg.session_idle_timeout_seconds)])
+
# If cli_path is a .js file, run it with node
# Note that we can't rely on the shebang as Windows doesn't support it
if cli_path.endswith(".js"):
diff --git a/python/test_client.py b/python/test_client.py
index eb132cd0d..ac1b735bf 100644
--- a/python/test_client.py
+++ b/python/test_client.py
@@ -204,6 +204,24 @@ def test_explicit_use_logged_in_user_false_without_token(self):
assert client._config.use_logged_in_user is False
+class TestSessionIdleTimeoutSeconds:
+ def test_accepts_session_idle_timeout_seconds(self):
+ client = CopilotClient(
+ SubprocessConfig(
+ cli_path=CLI_PATH,
+ session_idle_timeout_seconds=600,
+ log_level="error",
+ )
+ )
+ assert isinstance(client._config, SubprocessConfig)
+ assert client._config.session_idle_timeout_seconds == 600
+
+ def test_default_session_idle_timeout_seconds_is_none(self):
+ client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH, log_level="error"))
+ assert isinstance(client._config, SubprocessConfig)
+ assert client._config.session_idle_timeout_seconds is None
+
+
class TestOverridesBuiltInTool:
@pytest.mark.asyncio
async def test_overrides_built_in_tool_sent_in_tool_definition(self):