Sessions.ps1

function Get-CopilotSession {
    <#
    .SYNOPSIS
        Lists GitHub Copilot CLI sessions with directory, metadata, and age filters.
 
    .DESCRIPTION
        Enumerates session-state directories under ~/.copilot/session-state/ and
        parses each workspace.yaml to extract session metadata. Includes
        EventCount and EventSize for diagnosing oversized sessions.
 
        By default only sessions matching the current working directory are returned.
        Use -All to search every directory, or -Cwd to replace the implicit current
        directory scope. Repository, Branch, and Summary do not broaden that scope.
        All supplied filters must match. String filters use case-insensitive
        PowerShell wildcards against recorded metadata, without resolving paths.
        Results remain sorted by UpdatedAt descending.
 
        Missing repository, branch, or cwd metadata does not match even '*'.
        Summary matches the displayed Summary (name, then legacy summary, then
        '(no summary)'). Missing UpdatedAt never matches a date filter; no
        creation time or filesystem timestamp is substituted.
 
    .PARAMETER All
        Search sessions for all directories, still applying any supplied filters.
 
    .PARAMETER Id
        Return only the session with the specified ID. The ID must be a single
        session directory name (a GUID for CLI-created sessions); values with path
        separators, drive qualifiers, or relative segments are rejected so they
        cannot escape the session-state root. This is an exact lookup, independent
        of the current directory, and cannot be combined with All or filters.
 
    .PARAMETER Repository
        Match recorded Repository using a case-insensitive wildcard pattern.
 
    .PARAMETER Branch
        Match recorded Branch using a case-insensitive wildcard pattern.
 
    .PARAMETER Cwd
        Match recorded Cwd using a case-insensitive wildcard pattern instead of
        the implicit current-directory restriction. Paths are not normalized or
        resolved; escape literal wildcard characters with a PowerShell backtick.
 
    .PARAMETER Summary
        Match the displayed Summary using a case-insensitive wildcard pattern.
        Unnamed sessions have the displayed value '(no summary)'.
 
    .PARAMETER UpdatedBefore
        Return sessions whose UpdatedAt is strictly earlier than this DateTimeOffset
        instant. Use ISO 8601 with Z or an explicit offset for portable results.
        Input without an offset is interpreted in the local timezone. A date-only
        input means local midnight, not the end of that day.
 
    .PARAMETER OlderThan
        Return sessions last updated strictly more than this positive TimeSpan ago.
        Use New-TimeSpan -Days 30 or [timespan]::FromDays(30), not a bare number.
        The UTC clock is sampled once per invocation; days are elapsed 24-hour
        periods, not calendar days. With UpdatedBefore, both limits must match.
 
    .EXAMPLE
        Get-CopilotSession
        # Lists sessions for the current directory.
 
    .EXAMPLE
        Get-CopilotSession -All
        # Lists all sessions across every directory.
 
    .EXAMPLE
        Get-CopilotSession -Id "abc-123"
        # Returns the session with the given ID.
 
    .EXAMPLE
        Get-CopilotSession -All -Repository 'owner/*' -Branch 'feature/*' -Summary '*cleanup*'
        # Combines filters across every directory.
 
    .EXAMPLE
        Get-CopilotSession -All -UpdatedBefore '2026-08-01T00:00:00Z' -OlderThan (New-TimeSpan -Days 30) |
            Remove-CopilotSession -WhatIf
        # Previews cleanup of sessions matching both exclusive age cutoffs.
    #>

    [OutputType('CopilotSession')]
    [CmdletBinding(DefaultParameterSetName = 'Filter')]
    param(
        [Parameter(ParameterSetName = 'Filter')]
        [switch]$All,

        [Parameter(ParameterSetName = 'ById', Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [Parameter(ParameterSetName = 'Filter')]
        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string]$Repository,

        [Parameter(ParameterSetName = 'Filter')]
        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string]$Branch,

        [Parameter(ParameterSetName = 'Filter')]
        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string]$Cwd,

        [Parameter(ParameterSetName = 'Filter')]
        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string]$Summary,

        [Parameter(ParameterSetName = 'Filter')]
        [datetimeoffset]$UpdatedBefore,

        [Parameter(ParameterSetName = 'Filter')]
        [ValidateScript({ $_ -gt [timespan]::Zero }, ErrorMessage = 'OlderThan must be a positive TimeSpan.')]
        [timespan]$OlderThan
    )

    $filters = @{}
    foreach ($filter in 'Repository', 'Branch', 'Cwd', 'Summary', 'UpdatedBefore', 'OlderThan') {
        if ($PSBoundParameters.ContainsKey($filter)) {
            $filters[$filter] = $PSBoundParameters[$filter]
        }
    }
    $sessionStateDir = Join-Path (Get-CopilotHome) '.copilot' 'session-state'
    if (-not (Test-Path $sessionStateDir)) {
        return
    }

    $currentDirectory = (Get-Location).Path

    $dirs = if ($Id) {
        $target = Resolve-CopilotSessionPath -Id $Id
        if ($target) { Get-Item -LiteralPath $target } else { return }
    } else {
        Get-ChildItem $sessionStateDir -Directory
    }

    $dirs | ForEach-Object {
        $wsFile = Join-Path $_.FullName 'workspace.yaml'
        if (Test-Path $wsFile) {
            $content = Get-Content $wsFile -Raw
            $sessionCwd        = Get-CopilotWorkspaceField -Content $content -Field 'cwd'
            $updatedAt         = Get-CopilotWorkspaceField -Content $content -Field 'updated_at'
            $sessionSummary    = Get-CopilotWorkspaceField -Content $content -Field 'summary'
            $sessionBranch     = Get-CopilotWorkspaceField -Content $content -Field 'branch'
            $sessionRepository = Get-CopilotWorkspaceField -Content $content -Field 'repository'
            $createdAt         = Get-CopilotWorkspaceField -Content $content -Field 'created_at'
            $name              = Get-CopilotWorkspaceField -Content $content -Field 'name'
            if ($name) { $name = ($name -split '\r?\n', 2)[0].Trim() }

            if ($All -or $Id -or $filters.ContainsKey('Cwd') -or $sessionCwd -eq $currentDirectory) {
                $eventsFile = Join-Path $_.FullName 'events.jsonl'
                $eventCount = 0
                $eventSize  = [long]0
                if (Test-Path $eventsFile) {
                    $fi = [System.IO.FileInfo]::new($eventsFile)
                    $eventSize = $fi.Length
                    # Stream-count lines without allocating the full string array
                    $reader = [System.IO.StreamReader]::new($fi.FullName)
                    try {
                        while ($null -ne $reader.ReadLine()) { $eventCount++ }
                    } finally {
                        $reader.Dispose()
                    }
                }
                [PSCustomObject]@{
                    PSTypeName = 'CopilotSession'
                    Id         = $_.Name
                    Name       = $name ?? $sessionSummary ?? '(no summary)'
                    Summary    = $name ?? $sessionSummary ?? '(no summary)'
                    Cwd        = $sessionCwd
                    Branch     = $sessionBranch
                    Repository = $sessionRepository
                    CreatedAt  = if ($createdAt) { [DateTimeOffset]::Parse($createdAt) } else { $null }
                    UpdatedAt  = if ($updatedAt) { [DateTimeOffset]::Parse($updatedAt) } else { $null }
                    EventCount = $eventCount
                    EventSize  = $eventSize
                    Path       = $_.FullName
                }
            }
        }
    } | Select-CopilotSessionMatch @filters | Sort-Object UpdatedAt -Descending
}

function Remove-CopilotSession {
    <#
    .SYNOPSIS
        Removes one or more GitHub Copilot CLI sessions.
 
    .DESCRIPTION
        Deletes session-state directories by ID or by pipeline input from
        Get-CopilotSession. Supports -WhatIf and -Confirm via ShouldProcess.
 
        Each target is re-resolved by its ID through the session-state root guard
        before deletion, so an untrusted pipeline object's Path is never used and
        an ID that escapes the session-state root is rejected.
 
    .PARAMETER Id
        One or more session IDs to remove.
 
    .PARAMETER InputObject
        Session objects piped from Get-CopilotSession.
 
    .EXAMPLE
        Remove-CopilotSession -Id "abc-123"
        # Removes a single session by ID.
 
    .EXAMPLE
        Get-CopilotSession -All | Remove-CopilotSession
        # Removes all sessions.
 
    .EXAMPLE
        Get-CopilotSession | Remove-CopilotSession -WhatIf
        # Shows which sessions would be removed for the current directory.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ById')]
    param(
        [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Id,

        [Parameter(ParameterSetName = 'ByObject', Mandatory, ValueFromPipeline)]
        [PSObject]$InputObject
    )

    process {
        $ids = if ($PSCmdlet.ParameterSetName -eq 'ById') {
            $Id
        } elseif ($null -ne $InputObject) {
            @($InputObject.Id)
        } else {
            @()
        }

        foreach ($sid in $ids) {
            if ([string]::IsNullOrWhiteSpace($sid)) {
                Write-Error 'The pipeline input does not contain a session Id.'
                continue
            }
            # Re-resolve by ID so the deletion target is the guard-validated
            # canonical path, never a caller-supplied InputObject.Path.
            $s = Get-CopilotSession -Id $sid
            if ($null -eq $s) {
                Write-Error "Session '$sid' not found."
                continue
            }
            if ($PSCmdlet.ShouldProcess("$($s.Summary) ($($s.Id))", 'Remove session')) {
                Remove-Item -LiteralPath $s.Path -Recurse -Force
            }
        }
    }
}

function Rename-CopilotSession {
    <#
    .SYNOPSIS
        Changes the display name of a Copilot CLI session.
 
    .DESCRIPTION
        Updates the name and/or summary field in workspace.yaml for the specified
        session. Handles both the newer 'name' field (including YAML block scalars)
        and the legacy 'summary' field. If both exist, both are updated.
 
        The session is re-resolved by its ID through the session-state root guard
        before workspace.yaml is rewritten, so an untrusted pipeline object's Path
        is never used and an ID that escapes the session-state root is rejected.
 
    .PARAMETER Id
        The session ID to rename.
 
    .PARAMETER InputObject
        A session object piped from Get-CopilotSession.
 
    .PARAMETER Summary
        The new display name for the session.
 
    .EXAMPLE
        Rename-CopilotSession -Id "abc-123" -Name "Auth module refactor"
        # Sets the session name to "Auth module refactor".
 
    .EXAMPLE
        Get-CopilotSession | Where-Object Summary -eq '(no summary)' | Rename-CopilotSession -Name "Untitled session"
        # Renames all sessions with no name.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ById')]
    param(
        [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [Parameter(ParameterSetName = 'ByObject', Mandatory, ValueFromPipeline)]
        [PSObject]$InputObject,

        [Parameter(Mandatory, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [Alias('Name')]
        [string]$Summary
    )

    process {
        $sessionId = if ($PSCmdlet.ParameterSetName -eq 'ById') { $Id } else { $InputObject.Id }
        if ([string]::IsNullOrWhiteSpace($sessionId)) {
            Write-Error 'The pipeline input does not contain a session Id.'
            return
        }

        # Re-resolve by ID so the rewrite target is the guard-validated canonical
        # path, never a caller-supplied InputObject.Path.
        $session = Get-CopilotSession -Id $sessionId
        if ($null -eq $session) {
            Write-Error "Session '$sessionId' not found."
            return
        }

        $wsFile = Join-Path $session.Path 'workspace.yaml'
        if (-not (Test-Path $wsFile)) {
            Write-Error "workspace.yaml not found for session $($session.Id)."
            return
        }

        if (-not $PSCmdlet.ShouldProcess("$($session.Summary) ($($session.Id.Substring(0,8)))", "Rename to '$Summary'")) {
            return
        }

        $content = Get-Content $wsFile -Raw

        $content = Set-CopilotWorkspaceField -Content $content -Field 'name' -Value $Summary
        $content = Set-CopilotWorkspaceField -Content $content -Field 'summary' -Value $Summary

        [System.IO.File]::WriteAllText($wsFile, $content, [System.Text.UTF8Encoding]::new($false))
        Write-Verbose "Renamed session $($session.Id.Substring(0,8)) to '$Summary'"

        Get-CopilotSession -Id $session.Id
    }
}

function Resume-CopilotSession {
    <#
    .SYNOPSIS
        Resumes a specific GitHub Copilot CLI session by ID or pipeline input.
 
    .DESCRIPTION
        Launches the Copilot CLI with --resume targeting the specified session.
        Accepts a session ID directly or a session object piped from Get-CopilotSession.
 
    .PARAMETER Id
        The session ID to resume.
 
    .PARAMETER InputObject
        A session object piped from Get-CopilotSession.
 
    .PARAMETER Prompt
        Optional prompt to execute in autopilot mode within the resumed session.
 
    .PARAMETER RemainingArgs
        Any additional arguments passed through to the copilot executable.
 
    .EXAMPLE
        Resume-CopilotSession -Id "abc-123"
        # Resumes the session interactively.
 
    .EXAMPLE
        Get-CopilotSession | Select-Object -First 1 | Resume-CopilotSession
        # Resumes the most recent session for the current directory.
 
    .EXAMPLE
        Resume-CopilotSession -Id "abc-123" -Prompt "Continue refactoring"
        # Resumes the session in autopilot mode with the given prompt.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ById')]
    param(
        [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [Parameter(ParameterSetName = 'ByObject', Mandatory, ValueFromPipeline)]
        [PSObject]$InputObject,

        [Parameter(Position = 1)]
        [string]$Prompt,

        [Parameter(ValueFromRemainingArguments)]
        [string[]]$RemainingArgs
    )

    process {
        $sessionId = if ($PSCmdlet.ParameterSetName -eq 'ById') { $Id } else { $InputObject.Id }
        $session = Get-CopilotSession -Id $sessionId
        if ($null -eq $session) {
            Write-Error "Session '$sessionId' not found."
            return
        }

        $copilotExe = (Get-Command copilot -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
        $copilotArgs = @('--allow-all', '--experimental', '--resume', $session.Id)

        Write-Verbose "Resuming session: $($session.Summary)"
        Write-Verbose " Last updated $($session.UpdatedAt.LocalDateTime)"

        if ($Prompt) {
            $copilotArgs += '--autopilot', '-p', $Prompt
        }

        if ($RemainingArgs) {
            $copilotArgs += $RemainingArgs
        }

        & $copilotExe @copilotArgs
    }
}