Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions docs/features/session-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>timeout_warning"] --> C["🧹 30 min<br/>destroyed"]
A["⚡ Last Activity"] --> B["⏳ ~5 min before<br/>timeout_warning"] --> C["🧹 Timeout<br/>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) => {
Expand Down
5 changes: 5 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
UseStdio = other.UseStdio;
OnListModels = other.OnListModels;
SessionFs = other.SessionFs;
SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds;
}

/// <summary>
Expand Down Expand Up @@ -165,6 +166,15 @@ public string? GithubToken
/// </summary>
public TelemetryConfig? Telemetry { get; set; }

/// <summary>
/// Server-wide idle timeout for sessions in seconds.
/// Sessions without activity for this duration are automatically cleaned up.
/// Set to <c>0</c> or leave as <see langword="null"/> 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 <see cref="CliUrl"/>.
/// </summary>
public int? SessionIdleTimeoutSeconds { get; set; }

/// <summary>
/// Creates a shallow clone of this <see cref="CopilotClientOptions"/> instance.
/// </summary>
Expand Down
19 changes: 19 additions & 0 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
2 changes: 2 additions & 0 deletions dotnet/test/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
Environment = new Dictionary<string, string> { ["KEY"] = "value" },
GitHubToken = "ghp_test",
UseLoggedInUser = false,
SessionIdleTimeoutSeconds = 600,
};

var clone = original.Clone();
Expand All @@ -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]
Expand Down
8 changes: 8 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment thread
MackinnonBuck marked this conversation as resolved.
}

Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Comment thread
MackinnonBuck marked this conversation as resolved.
* @default undefined (disabled)
*/
sessionIdleTimeoutSeconds?: number;
Comment thread
MackinnonBuck marked this conversation as resolved.
}
Comment thread
MackinnonBuck marked this conversation as resolved.

/**
Expand Down
19 changes: 19 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
11 changes: 11 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
MackinnonBuck marked this conversation as resolved.
This option is only used when the SDK spawns the CLI process.
"""


@dataclass
class ExternalServerConfig:
Expand Down Expand Up @@ -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"):
Expand Down
18 changes: 18 additions & 0 deletions python/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading