private/Invoke-WtwWmuxProject.ps1

# wmux integration for wtw.
#
# wmux (https://github.com/amirlehmam/wmux) is a Windows terminal multiplexer.
# It ships a Node-based JSON-RPC CLI under `<install>/resources/cli/wmux.js`
# that talks to the running app over a named pipe. The supported surface used
# here:
# wmux new-workspace --title <name> --cwd <path> [--shell <pwsh>] -> {workspaceId}
# wmux list-workspaces -> {workspaces:[{id,title,cwd,...}]}
# wmux select-workspace <id> -> {ok:true}
# wmux close-workspace <id> -> {ok:true}
# wmux ping -> pong
#
# This mirrors what cmux does on macOS (Register/Open/Unregister a project), but
# wmux workspaces are live (daemon-backed) rather than a static config registry,
# so "registration" means creating the workspace in a running wmux.

function Get-WtwWmuxExe {
    [CmdletBinding()]
    param()

    # Explicit override wins.
    if ($env:WMUX_EXE -and (Test-Path $env:WMUX_EXE)) { return $env:WMUX_EXE }

    # Prefer an install that actually ships the bundled Node CLI wtw drives
    # (resources/cli/wmux.js) — there can be multiple wmux builds on a machine
    # (e.g. a Squirrel install under %LOCALAPPDATA%\WMUX that packages its CLI
    # differently). Fall back to the first existing exe so Start-WtwWmuxApp can
    # still try to launch something.
    $first = $null
    foreach ($exe in (Get-WtwWmuxExeCandidate)) {
        if (-not (Test-Path $exe)) { continue }
        if (-not $first) { $first = $exe }
        if (Resolve-WtwWmuxCliForExe -Exe $exe) { return $exe }
    }

    return $first
}

function Get-WtwWmuxExeCandidate {
    <#
    .SYNOPSIS
        Ordered list of candidate wmux.exe paths (not filtered by existence).
    #>

    [CmdletBinding()]
    param()

    $candidates = [System.Collections.Generic.List[string]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $add = {
        param($p)
        if ($p -and $seen.Add($p)) { $candidates.Add($p) }
    }

    if ($env:WMUX_EXE) { & $add $env:WMUX_EXE }

    # A running wmux tells us exactly where it was installed. Verify the image
    # path still exists in case the install was moved/renamed while running.
    $proc = Get-Process -Name wmux -ErrorAction SilentlyContinue |
        Where-Object { $_.Path -and (Test-Path $_.Path) } | Select-Object -First 1
    if ($proc) { & $add $proc.Path }

    $cmd = Get-Command wmux.exe -ErrorAction SilentlyContinue
    if ($cmd -and $cmd.Source) { & $add $cmd.Source }

    if (-not $IsWindows) { return $candidates }

    # Folder layouts wmux is commonly dropped into (it ships without an
    # installer, so users place it wherever, e.g. E:\ProgramFilesFolder\WMUX).
    $relativeDirs = @(
        'WMUX',
        'wmux',
        'ProgramFilesFolder/WMUX',
        'Program Files/WMUX',
        'Program Files (x86)/WMUX',
        'ProgramFiles/WMUX',
        'Programs/WMUX'
    )

    # Env-derived well-known roots first (fast, most likely).
    foreach ($root in @(
            (Join-Path $env:LOCALAPPDATA 'Programs'),
            $env:LOCALAPPDATA,
            $env:ProgramFiles,
            ${env:ProgramFiles(x86)},
            $env:ProgramW6432)) {
        if ($root) {
            & $add (Join-Path $root 'WMUX/wmux.exe')
            & $add (Join-Path $root 'wmux/wmux.exe')
        }
    }

    # Sweep attached drives (fixed + removable) with the common folder layouts.
    try {
        $driveRoots = [System.IO.DriveInfo]::GetDrives() |
            Where-Object { $_.IsReady -and ($_.DriveType.ToString() -in @('Fixed', 'Removable')) } |
            ForEach-Object { $_.RootDirectory.FullName }
    } catch {
        $driveRoots = @()
    }
    foreach ($drive in $driveRoots) {
        foreach ($rel in $relativeDirs) {
            & $add (Join-Path $drive (Join-Path $rel 'wmux.exe'))
        }
    }

    return $candidates
}

function Resolve-WtwWmuxCliForExe {
    <#
    .SYNOPSIS
        Resolve the bundled Node CLI (resources/cli/wmux.js) for a wmux.exe, or
        $null if that install doesn't ship it in the layout wtw supports.
    #>

    [CmdletBinding()]
    param([string] $Exe)

    if (-not $Exe) { return $null }
    $dir = Split-Path $Exe -Parent
    if (-not $dir) { return $null }

    # Portable layout: resources/cli/wmux.js next to wmux.exe.
    $direct = Join-Path $dir 'resources/cli/wmux.js'
    if (Test-Path $direct) { return $direct }

    # Squirrel layout: <dir>\app-<version>\resources\cli\wmux.js (newest first).
    $appDirs = Get-ChildItem -Path $dir -Directory -Filter 'app-*' -ErrorAction SilentlyContinue |
        Sort-Object Name -Descending
    foreach ($appDir in $appDirs) {
        $nested = Join-Path $appDir.FullName 'resources/cli/wmux.js'
        if (Test-Path $nested) { return $nested }
    }

    return $null
}

function Get-WtwWmuxCliScript {
    [CmdletBinding()]
    param()

    # wmux sets WMUX_CLI inside the shells it spawns; honor it when present.
    if ($env:WMUX_CLI -and (Test-Path $env:WMUX_CLI)) { return $env:WMUX_CLI }

    foreach ($exe in (Get-WtwWmuxExeCandidate)) {
        if (-not (Test-Path $exe)) { continue }
        $cli = Resolve-WtwWmuxCliForExe -Exe $exe
        if ($cli) { return $cli }
    }

    return $null
}

function Get-WtwWmuxNode {
    [CmdletBinding()]
    param()

    if ($env:WMUX_NODE -and (Test-Path $env:WMUX_NODE)) { return $env:WMUX_NODE }

    $cmd = Get-Command node -ErrorAction SilentlyContinue
    if ($cmd -and $cmd.Source) { return $cmd.Source }

    return $null
}

function Get-WtwWmuxInvoker {
    <#
    .SYNOPSIS
        Resolve how to run the wmux CLI.
    .DESCRIPTION
        Returns an object @{ Exe; Prefix } where invoking `& $Exe @Prefix @args`
        runs the wmux CLI. Preferred shape is `node <wmux.js>`; falls back to a
        `wmux` command on PATH if it is a real executable/script shim. Returns
        $null when no usable CLI can be found.
    #>

    [CmdletBinding()]
    param()

    $script = Get-WtwWmuxCliScript
    if ($script) {
        $node = Get-WtwWmuxNode
        if ($node) { return [PSCustomObject]@{ Exe = $node; Prefix = @($script) } }
    }

    $cmd = Get-Command wmux -ErrorAction SilentlyContinue
    if ($cmd -and $cmd.CommandType -in @('Application', 'ExternalScript') -and $cmd.Source) {
        return [PSCustomObject]@{ Exe = $cmd.Source; Prefix = @() }
    }

    return $null
}

function Test-WtwWmuxPresent {
    [CmdletBinding()]
    param()

    return [bool](Get-WtwWmuxInvoker)
}

function Invoke-WtwWmuxCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]] $ArgumentList
    )

    $invoker = Get-WtwWmuxInvoker
    if (-not $invoker) {
        return [PSCustomObject]@{ ExitCode = 127; Output = 'wmux CLI not found' }
    }

    $allArgs = @($invoker.Prefix) + $ArgumentList
    Write-Verbose "wmux command: $($invoker.Exe) $($allArgs -join ' ')"
    $output = & $invoker.Exe @allArgs 2>&1
    $outputText = $output -join [Environment]::NewLine
    $exitCode = $LASTEXITCODE
    if ($null -eq $exitCode) { $exitCode = 0 }

    return [PSCustomObject]@{ ExitCode = $exitCode; Output = $outputText }
}

function ConvertFrom-WtwWmuxJsonOutput {
    [CmdletBinding()]
    param([string] $Output)

    if ([string]::IsNullOrWhiteSpace($Output)) { return $null }
    try {
        return $Output | ConvertFrom-Json -Depth 100 -ErrorAction Stop
    } catch {
        return $null
    }
}

function Get-WtwWmuxObjectValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)][string[]] $Names
    )

    foreach ($name in $Names) {
        $prop = $Object.PSObject.Properties[$name]
        if ($prop -and $null -ne $prop.Value -and "$($prop.Value)" -ne '') {
            return $prop.Value
        }
    }

    return $null
}

function Test-WtwWmuxRunning {
    [CmdletBinding()]
    param()

    if (-not (Test-WtwWmuxPresent)) { return $false }
    $result = Invoke-WtwWmuxCommand -ArgumentList @('ping')
    return ($result.ExitCode -eq 0 -and $result.Output -match 'pong')
}

function Start-WtwWmuxApp {
    <#
    .SYNOPSIS
        Launch the wmux app and wait (briefly) for its CLI pipe to come up.
    #>

    [CmdletBinding()]
    param([int] $TimeoutSeconds = 12)

    $exe = Get-WtwWmuxExe
    if (-not $exe) { return $false }

    try {
        Start-Process -FilePath $exe | Out-Null
    } catch {
        return $false
    }

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        Start-Sleep -Milliseconds 500
        if (Test-WtwWmuxRunning) { return $true }
    }

    return (Test-WtwWmuxRunning)
}

function Confirm-WtwWmuxRunning {
    <#
    .SYNOPSIS
        Ensure wmux is running, starting it when necessary.
    #>

    [CmdletBinding()]
    param([switch] $StartIfStopped)

    if (Test-WtwWmuxRunning) { return $true }
    if (-not $StartIfStopped) { return $false }
    return (Start-WtwWmuxApp)
}

function Get-WtwWmuxShell {
    <#
    .SYNOPSIS
        Preferred shell for new wmux surfaces (pwsh, to match wtw's PS7 base).
        Returns $null to let wmux fall back to its configured default shell.
    #>

    [CmdletBinding()]
    param()

    $cmd = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($cmd -and $cmd.Source) { return $cmd.Source }
    return $null
}

function Get-WtwWmuxLiveWorkspaces {
    [CmdletBinding()]
    param()

    $result = Invoke-WtwWmuxCommand -ArgumentList @('list-workspaces')
    if ($result.ExitCode -ne 0) { return @() }

    $parsed = ConvertFrom-WtwWmuxJsonOutput -Output $result.Output
    if (-not $parsed) { return @() }
    if ($parsed.PSObject.Properties.Name -contains 'workspaces') { return @($parsed.workspaces) }
    if ($parsed -is [array]) { return @($parsed) }
    return @($parsed)
}

function Find-WtwWmuxWorkspace {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $PrettyName)

    foreach ($ws in (Get-WtwWmuxLiveWorkspaces)) {
        $name = Get-WtwWmuxObjectValue -Object $ws -Names @('title', 'name', 'displayName')
        if ([string]::Equals($name, $PrettyName, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $ws
        }
    }

    return $null
}

function New-WtwWmuxWorkspace {
    <#
    .SYNOPSIS
        Create a wmux workspace rooted at a directory. Assumes wmux is running.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectPath,
        [Parameter(Mandatory)][string] $PrettyName
    )

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)
    $argList = @('new-workspace', '--title', $PrettyName, '--cwd', $fullPath)

    $shell = Get-WtwWmuxShell
    if ($shell) { $argList += @('--shell', $shell) }

    $result = Invoke-WtwWmuxCommand -ArgumentList $argList
    if ($result.ExitCode -ne 0) {
        return [PSCustomObject]@{ Success = $false; Id = $null; Reason = $result.Output }
    }

    $parsed = ConvertFrom-WtwWmuxJsonOutput -Output $result.Output
    $id = if ($parsed) { Get-WtwWmuxObjectValue -Object $parsed -Names @('workspaceId', 'id') } else { $null }

    return [PSCustomObject]@{ Success = $true; Id = $id; Reason = $null }
}

function Open-WtwWmuxProject {
    <#
    .SYNOPSIS
        Open a wtw target as a wmux workspace: reuse a same-named workspace if one
        exists, otherwise create it. Starts wmux when it is not already running.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectPath,
        [Parameter(Mandatory)][string] $PrettyName,
        [string] $StatusValue
    )

    if (-not $IsWindows) {
        return [PSCustomObject]@{ Success = $false; Reason = 'wmux is Windows-only' }
    }
    if (-not (Test-WtwWmuxPresent)) {
        return [PSCustomObject]@{ Success = $false; Reason = 'wmux CLI not found' }
    }

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)

    if (-not (Confirm-WtwWmuxRunning -StartIfStopped)) {
        return [PSCustomObject]@{
            Success       = $false
            Created       = $false
            WorkspaceName = $PrettyName
            Path          = $fullPath
            Reason        = 'wmux is not running and could not be started.'
        }
    }

    $existing = Find-WtwWmuxWorkspace -PrettyName $PrettyName
    if ($existing) {
        $wsId = Get-WtwWmuxObjectValue -Object $existing -Names @('id', 'workspaceId', 'ref')
        if ($wsId) { Invoke-WtwWmuxCommand -ArgumentList @('select-workspace', "$wsId") | Out-Null }
        return [PSCustomObject]@{
            Success = $true; Created = $false; WorkspaceName = $PrettyName; Path = $fullPath; Id = $wsId; Reason = $null
        }
    }

    $created = New-WtwWmuxWorkspace -ProjectPath $fullPath -PrettyName $PrettyName
    if (-not $created.Success) {
        return [PSCustomObject]@{
            Success = $false; Created = $false; WorkspaceName = $PrettyName; Path = $fullPath; Reason = $created.Reason
        }
    }

    if ($created.Id) { Invoke-WtwWmuxCommand -ArgumentList @('select-workspace', "$($created.Id)") | Out-Null }
    return [PSCustomObject]@{
        Success = $true; Created = $true; WorkspaceName = $PrettyName; Path = $fullPath; Id = $created.Id; Reason = $null
    }
}

function Register-WtwWmuxProject {
    <#
    .SYNOPSIS
        Create a wmux workspace for a worktree (called by `wtw add` / `wtw create`).
    .DESCRIPTION
        wmux has no static SourceGit-style on-disk repository registry; its
        workspaces are live. So registration here means creating the workspace in
        a running wmux (starting wmux if needed). Best-effort: never blocks
        worktree setup. Returns the workspace title (stored as wmuxWorkspaceName)
        so it can be found/closed later, or $null when nothing was created.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectPath,
        [Parameter(Mandatory)][string] $PrettyName,
        [string] $RepoName,
        [string] $TaskName
    )

    if (-not $IsWindows) { return $null }
    if (-not (Test-WtwWmuxPresent)) {
        Write-Host ' wmux: CLI not found - skipping workspace creation.' -ForegroundColor DarkGray
        return $null
    }

    if (-not (Confirm-WtwWmuxRunning -StartIfStopped)) {
        $hint = if ($TaskName) { "wtw wmux $TaskName" } else { 'wtw wmux <name>' }
        Write-Host " wmux: app not running - skipped. Run '$hint' to create the workspace once wmux is open." -ForegroundColor DarkGray
        return $null
    }

    $existing = Find-WtwWmuxWorkspace -PrettyName $PrettyName
    if ($existing) {
        Write-Host " wmux: workspace already exists '$PrettyName'." -ForegroundColor DarkGray
        return $PrettyName
    }

    $created = New-WtwWmuxWorkspace -ProjectPath $ProjectPath -PrettyName $PrettyName
    if (-not $created.Success) {
        Write-Host " wmux: could not create workspace - $($created.Reason)" -ForegroundColor Yellow
        return $null
    }

    Write-Host " wmux: created workspace '$PrettyName'" -ForegroundColor Green
    return $PrettyName
}

function Unregister-WtwWmuxProject {
    <#
    .SYNOPSIS
        Close the wmux workspace created for a worktree (called by `wtw remove`).
    #>

    [CmdletBinding()]
    param([string] $PrettyName)

    if (-not $IsWindows) { return }
    if (-not $PrettyName) { return }
    if (-not (Test-WtwWmuxPresent)) { return }
    if (-not (Test-WtwWmuxRunning)) { return }

    $workspace = Find-WtwWmuxWorkspace -PrettyName $PrettyName
    if (-not $workspace) { return }

    $workspaceId = Get-WtwWmuxObjectValue -Object $workspace -Names @('id', 'workspaceId', 'ref')
    if (-not $workspaceId) { return }

    $result = Invoke-WtwWmuxCommand -ArgumentList @('close-workspace', "$workspaceId")
    if ($result.ExitCode -eq 0) {
        Write-Host " wmux: closed workspace '$PrettyName'." -ForegroundColor Green
    }
}