SessionSelection.ps1

function ConvertTo-CopilotAdoRepositoryIdentity {
    param([Parameter(Mandatory)][string]$Url)

    if ($Url -match '[\s\\]') { return $null }
    $organization = $null
    $project = $null
    $repository = $null
    if ($Url -match '^https://(?:(?<username>[^@/:?#]+)@)?dev\.azure\.com/(?<organization>[^/]+)/(?:(?:DefaultCollection)/)?(?:(?<project>[^/]+)/)?_git/(?<repository>[^/]+)/?$' -or
        $Url -match '^https://(?:(?<username>[^@/:?#]+)@)?(?<organization>[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.visualstudio\.com/(?:(?:DefaultCollection)/)?(?:(?<project>[^/]+)/)?_git/(?<repository>[^/]+)/?$' -or
        $Url -match '^(?:git@ssh\.dev\.azure\.com:|ssh://git@ssh\.dev\.azure\.com/)v3/(?<organization>[^/]+)/(?<project>[^/]+)/(?<repository>[^/]+)/?$') {
        $organization, $project, $repository = $Matches['organization'], $Matches['project'], $Matches['repository']
        $username = $Matches['username']
    } else {
        return $null
    }

    if ($username) {
        if ($username -match '%(?![0-9a-fA-F]{2})') { return $null }
        $username = [uri]::UnescapeDataString($username)
        if ($username -match '[/\\:?#\p{Cc}\p{Cf}\p{Zl}\p{Zp}\uFFFD]' -or
            [string]::IsNullOrWhiteSpace($username) -or $username -cne $username.Trim()) {
            return $null
        }
    }
    $projectOmitted = -not $project
    if ($projectOmitted) { $project = $repository }
    $segments = @($organization, $project, $repository)
    for ($i = 0; $i -lt $segments.Count; $i++) {
        if ($segments[$i] -match '%(?![0-9a-fA-F]{2})') { return $null }
        $segments[$i] = [uri]::UnescapeDataString($segments[$i])
        if ($segments[$i] -match '[/\\?#\p{Cc}\p{Cf}\p{Zl}\p{Zp}\uFFFD]' -or
            $segments[$i] -in @('.', '..') -or [string]::IsNullOrWhiteSpace($segments[$i]) -or
            $segments[$i] -cne $segments[$i].Trim()) {
            return $null
        }
    }
    $organization, $project, $repository = $segments
    if ($organization -notmatch '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$') { return $null }
    if ($repository.EndsWith('.git', [StringComparison]::OrdinalIgnoreCase)) {
        $repository = $repository.Substring(0, $repository.Length - 4)
    }
    if ([string]::IsNullOrWhiteSpace($repository) -or $repository -in @('.', '..')) { return $null }
    if ($projectOmitted) { $project = $repository }
    return "$($organization.ToLowerInvariant())/$project/$repository"
}

function ConvertTo-CopilotGitHubRepositoryIdentity {
    param([Parameter(Mandatory)][string]$Url)

    if ($Url -match '\A(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)/([a-z0-9_.-]+?)(?:\.git)?/?\z' -and
        $Matches[2] -notin @('.', '..', '.git')) {
        return "$($Matches[1])/$($Matches[2])"
    }
}

function Test-CopilotRemoteRepository {
    $previousExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction Ignore
    $previousExitCodeValue = if ($previousExitCode) { $previousExitCode.Value }
    $PSNativeCommandUseErrorActionPreference = $false
    try {
        $global:LASTEXITCODE = $null
        $insideWorktree = git rev-parse --is-inside-work-tree 2>$null
        if ($LASTEXITCODE -ne 0 -or $insideWorktree -cne 'true') { return $false }

        $global:LASTEXITCODE = $null
        $urls = @(git remote get-url --all origin 2>$null)
        if ($LASTEXITCODE -ne 0 -or $urls.Count -ne 1) { return $false }
        return [bool](ConvertTo-CopilotGitHubRepositoryIdentity -Url $urls[0])
    } catch {
        Write-Verbose "Cannot determine Copilot remote eligibility: $($_.Exception.Message)"
        return $false
    } finally {
        if ($previousExitCode) {
            $global:LASTEXITCODE = $previousExitCodeValue
        } else {
            Remove-Variable -Name LASTEXITCODE -Scope Global -WhatIf:$false -Confirm:$false
        }
    }
}

function Resolve-CopilotRemoteArguments {
    param(
        [string[]]$Arguments,
        [switch]$Remote,
        [switch]$NoRemote
    )

    $remoteRequested = [bool]$Remote
    $remoteDisabled = [bool]$NoRemote
    $remaining = [System.Collections.Generic.List[string]]::new()
    $endOfOptions = $false
    foreach ($argument in $Arguments) {
        if (-not $endOfOptions -and $argument -ceq '--remote') {
            $remoteRequested = $true
        } elseif (-not $endOfOptions -and $argument -ceq '--no-remote') {
            $remoteDisabled = $true
        } else {
            $remaining.Add($argument)
            if ($argument -ceq '--') { $endOfOptions = $true }
        }
    }

    if ($remoteDisabled) {
        '--no-remote'
    } elseif ($remoteRequested) {
        if (Test-CopilotRemoteRepository) {
            '--remote'
        } else {
            Write-Warning 'Copilot remote control was not enabled: the effective launch directory must be a Git worktree with one recognized GitHub origin. Continuing locally with --no-remote.'
            '--no-remote'
        }
    }
    $remaining.ToArray()
}

function Get-CopilotSessionScope {
    $cwd = (Get-Location).Path
    $branch = try { git symbolic-ref --short HEAD 2>$null } catch { $null }
    $repository = $null
    if ($branch) {
        $urls = @(try { git config --get-all remote.origin.url 2>$null } catch { @() })
        if ($urls.Count -eq 1) {
            $repository = ConvertTo-CopilotGitHubRepositoryIdentity -Url $urls[0]
            if (-not $repository) {
                $repository = ConvertTo-CopilotAdoRepositoryIdentity -Url $urls[0]
            }
        }
    }
    [pscustomobject]@{ Cwd = $cwd; Repository = $repository; Branch = $branch }
}

function Test-CopilotSessionScope {
    param(
        [Parameter(Mandatory)]$Scope,
        [string]$Cwd,
        [string]$Repository,
        [string]$Branch
    )

    if ($Scope.Repository -and $Scope.Branch) {
        if ($Repository -and $Branch) {
            return ($Repository -ieq $Scope.Repository -and $Branch -ceq $Scope.Branch)
        }
        if ($Repository -and $Repository -ine $Scope.Repository) { return $false }
        if ($Branch -and $Branch -cne $Scope.Branch) { return $false }
    }
    return ($Cwd -eq $Scope.Cwd)
}

function Complete-CopilotResumeSession {
    [CmdletBinding()]
    param(
        [string]$WordToComplete,
        [System.Collections.IDictionary]$BoundParameters
    )

    $changeDir = if ($BoundParameters -and $BoundParameters.Contains('ChangeDir')) {
        $BoundParameters['ChangeDir']
    } elseif ($BoundParameters -and $BoundParameters.Contains('C')) {
        $BoundParameters['C']
    } else { $null }
    $originalLocation = $null
    try {
        if ($changeDir) {
            $directory = Get-Item -LiteralPath $changeDir -ErrorAction Stop
            if ($directory.PSProvider.Name -ne 'FileSystem' -or -not $directory.PSIsContainer) {
                throw "ChangeDir must be an existing filesystem directory: '$changeDir'."
            }
            $originalLocation = Get-Location
            Set-Location -LiteralPath $directory.FullName -ErrorAction Stop
        }
        $sessionStateDir = Join-Path (Get-CopilotHome) '.copilot' 'session-state'
        if (-not (Test-Path -LiteralPath $sessionStateDir)) { return }
        $scope = Get-CopilotSessionScope
        Get-ChildItem -LiteralPath $sessionStateDir -Directory | Where-Object {
            $_.Name.StartsWith($WordToComplete, [StringComparison]::OrdinalIgnoreCase)
        } | ForEach-Object {
            $wsFile = Join-Path $_.FullName 'workspace.yaml'
            if (-not (Test-Path -LiteralPath $wsFile)) { return }
            $content = Get-Content -LiteralPath $wsFile -Raw -ErrorAction Stop
            $sessionCwd = Get-CopilotWorkspaceField -Content $content -Field 'cwd'
            $sessionRepository = Get-CopilotWorkspaceField -Content $content -Field 'repository'
            $sessionBranch = Get-CopilotWorkspaceField -Content $content -Field 'branch'
            $identity = Resolve-CopilotSessionIdentity -SessionPath $_.FullName -Cwd $sessionCwd -Repository $sessionRepository -Branch $sessionBranch -Scope $scope
            if ($identity.RecoveryError) { $PSCmdlet.WriteError($identity.RecoveryError) }
            if (Test-CopilotSessionScope -Scope $scope -Cwd $identity.Cwd -Repository $identity.Repository -Branch $identity.Branch) {
                $name = Get-CopilotWorkspaceField -Content $content -Field 'name'
                if ($name) { $name = ($name -split '\r?\n', 2)[0].Trim() }
                $summary = Get-CopilotWorkspaceField -Content $content -Field 'summary'
                $updatedAt = Get-CopilotWorkspaceField -Content $content -Field 'updated_at'
                [pscustomobject]@{
                    Id        = $_.Name
                    Display   = $name ?? $summary ?? '(no summary)'
                    UpdatedAt = if ($updatedAt) { [DateTimeOffset]::Parse($updatedAt) } else { $null }
                }
            }
        } | Sort-Object UpdatedAt -Descending | ForEach-Object {
            [System.Management.Automation.CompletionResult]::new($_.Id, $_.Id, 'ParameterValue', $_.Display)
        }
    } finally {
        if ($originalLocation) { Set-Location -LiteralPath $originalLocation.Path -ErrorAction Stop }
    }
}

function Get-CopilotResumeCandidate {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param()

    $sessionStateDir = Join-Path (Get-CopilotHome) '.copilot' 'session-state'
    $ignoredSessionNames = @(
        'Apply context_board add/prune updates for this session. End the turn with a 2-3 sentence summary of the changes you made to the context_board.'
        'Analyze the session file and write the session insights result to the specified output file as described in the instructions.'
        'Session File Path:'
    )
    if (-not (Test-Path $sessionStateDir)) {
        return
    }

    $scope = Get-CopilotSessionScope
    $sessions = @(Get-ChildItem $sessionStateDir -Directory |
        ForEach-Object {
            $sessionPath = Resolve-CopilotSessionPath -Id $_.Name -ErrorAction Stop
            if (-not $sessionPath) {
                throw "Copilot session candidate '$($_.Name)' no longer exists."
            }
            $wsFile = Join-Path $sessionPath 'workspace.yaml'
            if (Test-Path $wsFile) {
                $content = Get-Content $wsFile -Raw
                $workspace = Get-CopilotWorkspaceFields -Content $content -Fields @(
                    'cwd', 'updated_at', 'summary', 'branch', 'repository', 'name'
                )
                $sessionCwd = $workspace['cwd']
                $updatedAt = $workspace['updated_at']
                $summary = $workspace['summary']
                $sessionBranch = $workspace['branch']
                $sessionRepository = $workspace['repository']
                $identity = Resolve-CopilotSessionIdentity -SessionPath $sessionPath -Cwd $sessionCwd -Repository $sessionRepository -Branch $sessionBranch -Scope $scope
                if ($identity.RecoveryError) { $PSCmdlet.WriteError($identity.RecoveryError) }
                $sessionName = $workspace['name']
                if ($sessionName) { $sessionName = ($sessionName -split '\r?\n', 2)[0].Trim() }
                $displayName = $sessionName ?? $summary ?? '(no summary)'
                if ((Test-CopilotSessionScope -Scope $scope -Cwd $identity.Cwd -Repository $identity.Repository -Branch $identity.Branch) -and
                    $updatedAt -and $sessionName -notin $ignoredSessionNames) {
                    [PSCustomObject]@{
                        PSTypeName = 'CopilotSession'
                        Id         = $_.Name
                        Name       = $displayName
                        Summary    = $displayName
                        Branch     = $identity.Branch
                        Repository = $identity.Repository
                        UpdatedAt  = [DateTimeOffset]::Parse($updatedAt)
                        Cwd        = $identity.Cwd
                    }
                }
            }
        } |
        Sort-Object UpdatedAt -Descending)

    # Prefer full identity over directory-only legacy candidates when available.
    if ($scope.Repository -and $sessions.Count -gt 0) {
        $identityMatches = @($sessions | Where-Object {
            $_.Repository -ieq $scope.Repository -and $_.Branch -ceq $scope.Branch
        })
        if ($identityMatches.Count -gt 0) { return $identityMatches }
    }
    # Retain the existing branch preference for directory-only fallback.
    if ($scope.Branch -and $sessions.Count -gt 0) {
        $branchMatches = @($sessions | Where-Object { $_.Branch -and $_.Branch -ceq $scope.Branch })
        if ($branchMatches.Count -gt 0) {
            $sessions = $branchMatches
        }
    }
    return $sessions
}

function ConvertTo-CopilotSessionDisplayText {
    param([string]$Text)

    # Keep Unicode graphemes (including joiners) intact, but prevent terminal
    # controls and line separators from acting as UI rather than session data.
    $Text -replace '[\p{Cc}\p{Zl}\p{Zp}]', ' '
}

function Invoke-CopilotSessionChoice {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [object[]]$Sessions,

        [Parameter(Mandatory)]
        [string]$Caption,

        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter(Mandatory)]
        [string]$ExitLabel,

        [Parameter(Mandatory)]
        [string]$ExitHelp
    )

    $nameCounts = [System.Collections.Generic.Dictionary[string, int]]::new([StringComparer]::Ordinal)
    $displaySessions = @(
        foreach ($session in $Sessions) {
            $name = ConvertTo-CopilotSessionDisplayText $session.Summary
            $elements = [System.Globalization.StringInfo]::new($name)
            $displayName = if ($elements.LengthInTextElements -gt 80) {
                $elements.SubstringByTextElements(0, 77) + '...'
            } else {
                $name
            }
            if (-not $nameCounts.ContainsKey($displayName)) { $nameCounts[$displayName] = 0 }
            $nameCounts[$displayName]++
            [pscustomobject]@{
                Name = $name
                DisplayName = $displayName
                Branch = ConvertTo-CopilotSessionDisplayText $session.Branch
            }
        }
    )
    # C/M/N/P are reserved for Cancel, Next page, New session, and Previous page.
    $sessionKeys = 'ABDEFGHIJKLOQRSTUVWXYZ'
    $pageSize = $sessionKeys.Length
    $pageStart = 0
    $pageCount = [int][Math]::Ceiling($Sessions.Count / [double]$pageSize)
    # A method failure must terminate even when the caller uses Continue.
    $ErrorActionPreference = 'Stop'
    while ($true) {
        $choices = [System.Collections.ObjectModel.Collection[System.Management.Automation.Host.ChoiceDescription]]::new()
        $sessionCount = [Math]::Min($pageSize, $Sessions.Count - $pageStart)
        $pageEnd = $pageStart + $sessionCount
        for ($i = $pageStart; $i -lt $pageEnd; $i++) {
            $session = $Sessions[$i]
            $display = $displaySessions[$i]
            $branchSuffix = if ($nameCounts[$display.DisplayName] -gt 1 -and -not [string]::IsNullOrWhiteSpace($display.Branch)) {
                " ($($display.Branch))"
            } else {
                ''
            }
            $updatedAt = if ($session.UpdatedAt) { $session.UpdatedAt.ToString('o') } else { '(unknown)' }
            $help = "Id: $($session.Id)`nName: $($display.Name)`nRepository: $($session.Repository)`nBranch: $($display.Branch)`nCwd: $($session.Cwd)`nUpdated: $updatedAt`nEvents: $($session.EventCount)"
            # The host consumes the first '&'; later ampersands stay session data.
            $label = "&$($sessionKeys[$i - $pageStart]) - $($display.DisplayName)$branchSuffix"
            $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new($label, $help))
        }
        $previousChoice = -1
        $nextChoice = -1
        if ($pageStart -gt 0) {
            $previousChoice = $choices.Count
            $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new(
                '&P - Previous page', 'Show the previous sessions without selecting one.'))
        }
        if ($pageEnd -lt $Sessions.Count) {
            $nextChoice = $choices.Count
            $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new(
                '&M - Next page', 'Show the next sessions without selecting one.'))
        }
        $choices.Add([System.Management.Automation.Host.ChoiceDescription]::new($ExitLabel, $ExitHelp))
        $pageNumber = [int]($pageStart / $pageSize) + 1
        $promptMessage = "$Message`n`nSessions $($pageStart + 1)-$pageEnd of $($Sessions.Count) (page $pageNumber of $pageCount).`nChoose a session key or an action. Use choice help for full names, IDs, and workspace details."

        try {
            if ($null -eq $Host.UI) {
                throw [System.NotSupportedException]::new('The active host has no user interface.')
            }
            $selected = $Host.UI.PromptForChoice($Caption, $promptMessage, $choices, -1)
        } catch {
            throw [System.InvalidOperationException]::new(
                "Session selection requires a host with working PromptForChoice input. Supply -SessionSelector or choose a session explicitly. Host error: $($_.Exception.Message)",
                $_.Exception)
        }
        if ($selected -isnot [int] -or $selected -lt 0 -or $selected -ge $choices.Count) {
            throw "The host returned an invalid session choice '$selected'. Supply -SessionSelector or choose a session explicitly."
        }
        if ($selected -lt $sessionCount) { return $Sessions[$pageStart + $selected] }
        if ($selected -eq $previousChoice) {
            $pageStart -= $pageSize
        } elseif ($selected -eq $nextChoice) {
            $pageStart = $pageEnd
        } else {
            return $null
        }
    }
}

function Invoke-CopilotLaunchSessionPicker {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [object[]]$Sessions,

        [scriptblock]$SessionSelector
    )

    if ($SessionSelector) {
        return Invoke-CopilotSessionSelector -Sessions $Sessions -SessionSelector $SessionSelector
    }
    $picked = Invoke-CopilotSessionChoice -Sessions $Sessions -Caption 'Choose Copilot session' `
        -Message 'Choose a session for the current folder, or New session to start without resuming.' `
        -ExitLabel '&New session' -ExitHelp 'Start a new session instead of resuming an existing one.'
    if ($picked) {
        Write-Host "Resuming session: $(ConvertTo-CopilotSessionDisplayText $picked.Summary)" -ForegroundColor Cyan
    }
    return $picked
}

function Invoke-CopilotSessionSelector {
    [CmdletBinding()]
    [OutputType('CopilotSession')]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Sessions,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [scriptblock]$SessionSelector
    )

    if ($Sessions.Count -eq 0) {
        return $null
    }

    # Keep canonical metadata separate from the objects given to caller code.
    $candidates = @($Sessions | ForEach-Object {
        if ($_.PSObject.TypeNames -notcontains 'CopilotSession') {
            throw 'Session candidates must be CopilotSession objects.'
        }
        if (-not (Resolve-CopilotSessionPath -Id $_.Id -ErrorAction Stop)) {
            throw "Copilot session candidate '$($_.Id)' no longer exists."
        }
        $_.PSObject.Copy()
    })
    $selectorSessions = @($candidates | ForEach-Object { $_.PSObject.Copy() })
    $output = @(& $SessionSelector $selectorSessions 2>&1)
    foreach ($item in $output) {
        if ($item -is [System.Management.Automation.ErrorRecord]) {
            $PSCmdlet.ThrowTerminatingError($item)
        }
    }

    if ($output.Count -eq 0 -or ($output.Count -eq 1 -and $null -eq $output[0])) {
        return $null
    }
    if ($output.Count -ne 1 -or
        $output[0].PSObject.TypeNames -notcontains 'CopilotSession' -or
        $output[0].Id -isnot [string] -or
        [string]::IsNullOrWhiteSpace($output[0].Id)) {
        throw 'SessionSelector must return exactly one CopilotSession candidate or $null (no output).'
    }

    $selectedId = $output[0].Id
    $selected = @($candidates | Where-Object { $_.Id -ceq $selectedId })
    if ($selected.Count -ne 1) {
        throw "SessionSelector returned a session that is not a candidate: '$selectedId'."
    }
    if (-not (Resolve-CopilotSessionPath -Id $selectedId -ErrorAction Stop)) {
        throw "Selected Copilot session '$selectedId' no longer exists."
    }
    return $selected[0]
}