#requires -Version 5.1
<#
.SYNOPSIS
OpenCode desktop clone-instance launcher for GHOST.
.DESCRIPTION
Launches ONE fully isolated OpenCode desktop instance beside the Primary
and/or other clones. Mechanism (docs/OPENCODE_CLONES_SPEC.md, verified
statically against the installed 2.0.32 asar; LIVE-PENDING items gated at
first run): the installed app carries the clone-home asar patch
(tools/patch_opencode_clone_home.ps1) which redirects, per launch flag:
--opencode-clone-home=
userData -> \desktop,
sessionData -> \session, and the app's
own XDG_DATA/CONFIG/CACHE/STATE_HOME ->
\{data,config,cache,state}
The per-clone Electron userData gives each clone its OWN
requestSingleInstanceLock (keyed on userData), so N copies coexist
natively. The same flag drives the patched setAppUserModelId wrap +
browser-window-created listener: every clone window declares
ai.opencode.desktop. (matching the shortcuts' AUMIDs), the per-color
window icon and the "OpenCode " title, from
\desktop\.opencode-clone-identity.json (written fresh by this
launcher on every launch).
NO seeding and NO credential copying (one credential = one instance):
a fresh clone starts anonymous - ONE manual sign-in per clone. Clone
"reset" = stop the instance, delete \, launch again.
Launch instances SEQUENTIALLY (use launch_opencode_clones_all.ps1);
never elevated (UIPI rule applies even though the app itself is
asInvoker). A bare OpenCode.exe launch (or a stock protocol launch)
always opens the PRIMARY profile - launch clones only through this
launcher or the per-clone shortcuts.
.PARAMETER Instance
Clone slot: c1..c5 (validated).
.PARAMETER Status
Show this clone's status (home tree, lockfile, identity, sign-in hint,
process match) WITHOUT launching.
.EXAMPLE
powershell -ExecutionPolicy Bypass -File launch_opencode_clone.ps1 -Instance c1
powershell -ExecutionPolicy Bypass -File launch_opencode_clone.ps1 -Instance c3 -Status
#>
param(
[ValidateSet('c1','c2','c3','c4','c5')]
[string]$Instance = 'c1',
[switch]$Status
)
$ErrorActionPreference = 'Stop'
# --- Instance definitions (MUST stay in sync with
# tools/install_opencode_clone_shortcuts.ps1 $InstanceDefs) -------------------
$InstanceDefs = @{
'c1' = @{ Color = 'Blue'; AumidSuffix = 'c1' }
'c2' = @{ Color = 'Yellow'; AumidSuffix = 'c2' }
'c3' = @{ Color = 'Green'; AumidSuffix = 'c3' }
'c4' = @{ Color = 'Red'; AumidSuffix = 'c4' }
'c5' = @{ Color = 'Orange'; AumidSuffix = 'c5' } # generated placeholder - drop developer renders at tools\opencode-orange-branding\
}
# --- Portable paths ----------------------------------------------------------
$ExeCandidates = @(
(Join-Path $env:LOCALAPPDATA 'Programs\@opencode-aidesktop\OpenCode.exe'),
(Join-Path $env:LOCALAPPDATA 'Programs\opencode\OpenCode.exe')
)
$ExePath = $null
foreach ($c in $ExeCandidates) { if (Test-Path $c) { $ExePath = $c; break } }
$ResourcesDir = if ($ExePath) { Join-Path (Split-Path $ExePath -Parent) 'resources' } else { $null }
$AsarPath = if ($ResourcesDir) { Join-Path $ResourcesDir 'app.asar' } else { $null }
$ClonesRoot = Join-Path $env:USERPROFILE 'OpenCodeClonesHome'
$CloneHome = Join-Path $ClonesRoot $Instance
$DesktopDir = Join-Path $CloneHome 'desktop' # Electron userData (patched setPath target)
$Lockfile = Join-Path $DesktopDir 'lockfile'
$IdentityJson = Join-Path $DesktopDir '.opencode-clone-identity.json'
$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # src\windows -> repo root
$BrandingDir = Join-Path $RepoRoot ("tools\opencode-" + ($InstanceDefs[$Instance].Color.ToLowerInvariant()) + "-branding")
function Test-LockfileHeld {
param([string]$Path)
if (-not (Test-Path $Path)) { return $false }
try {
$fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'None')
$fs.Close()
return $false
} catch { return $true }
}
function Get-CloneProcess {
# Marker-first classification: a main OpenCode.exe whose command line
# carries this clone's home flag. WMI may return empty command lines for
# some processes (the AutoClaw PEB-scrub precedent) - callers fall back to
# the lockfile probe for those.
$needle = "--opencode-clone-home=" + $CloneHome
$procs = @(Get-CimInstance Win32_Process -Filter "Name='OpenCode.exe'" -ErrorAction SilentlyContinue)
$matched = @()
foreach ($p in $procs) {
if ($p.CommandLine -and $p.CommandLine.Contains($needle)) { $matched += $p }
}
return @{ All = $procs; Matched = $matched; CommandLineReadable = @($procs | Where-Object { $_.CommandLine }).Count -gt 0 }
}
# --- Status mode --------------------------------------------------------------
if ($Status) {
Write-Host "=== OpenCode clone '$Instance' status ==="
Write-Host "clone home : $CloneHome"
Write-Host "exists : $(Test-Path $CloneHome)"
foreach ($sub in @('desktop','session','data','config','cache','state')) {
$p = Join-Path $CloneHome $sub
if (Test-Path $p) { Write-Host (" {0,-8}: present" -f $sub) }
}
if (Test-LockfileHeld $Lockfile) {
Write-Host 'lockfile : HELD (instance running)'
} elseif (Test-Path $Lockfile) {
Write-Host 'lockfile : free (cleanly stopped)'
} else {
Write-Host 'lockfile : absent (never started)'
}
$procInfo = Get-CloneProcess
if ($procInfo.Matched.Count -gt 0) {
Write-Host ("process : {0} OpenCode.exe process(es) carry this clone's flag" -f $procInfo.Matched.Count)
} elseif (-not $procInfo.CommandLineReadable) {
Write-Host 'process : OpenCode.exe running but command lines unreadable (WMI) - trust the lockfile probe'
} else {
Write-Host 'process : no process carries this clone flag'
}
if (Test-Path $IdentityJson) {
try {
$id = Get-Content -LiteralPath $IdentityJson -Raw | ConvertFrom-Json
Write-Host "identity : aumidSuffix=$($id.aumidSuffix) title='$($id.title)'"
} catch { Write-Host 'identity : marker unreadable (will be rewritten on next launch)' }
} else {
Write-Host 'identity : not written yet (first launch pending)'
}
$auth = Join-Path $CloneHome 'data\auth.json'
if (Test-Path $auth) {
Write-Host 'sign-in : data\auth.json present (verify in-app)'
} else {
Write-Host 'sign-in : no auth.json yet (anonymous - ONE manual sign-in pending)'
}
return
}
# --- Preflight ----------------------------------------------------------------
if (-not $ExePath) {
Write-Error "OpenCode.exe not found under $env:LOCALAPPDATA\Programs (looked in @opencode-aidesktop and opencode)"
exit 1
}
Write-Host "install : $ExePath"
# Patch gate: a launch WITHOUT the patch in place would open the PRIMARY
# profile (the flag alone does nothing stock) - abort loudly instead.
Write-Host "[1/5] Verifying the clone-home patch..."
$patchOk = $false
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd -and (Test-Path $AsarPath)) {
$probe = & $nodeCmd.Source -e "const fs=require('fs');try{const b=fs.readFileSync(process.argv[1]);const need=['__opencodeCloneHome','__opencodeCloneIdentity','--opencode-clone-home'];const missing=need.filter(s=>!b.includes(s));process.exit(missing.length?1:0)}catch(e){process.exit(3)}" $AsarPath
$patchOk = ($LASTEXITCODE -eq 0)
}
if (-not $patchOk) {
Write-Warning "The clone-home patch is NOT applied to this OpenCode install."
Write-Warning "Launching now would open the PRIMARY profile, not the clone. Run GHOST.bat -> [6] OpenCode Multi-Instance & Clones -> [7] Apply clone-home patch (app must be closed), then relaunch."
exit 1
}
Write-Host ' patch signatures present.'
# Home tree: pre-create so the identity marker can be written before first boot
# (ye() would mkdir the same layout anyway - this makes first boot identical to
# later boots).
Write-Host "[2/5] Preparing clone home..."
foreach ($sub in @('desktop','session','data','config','cache','state')) {
$p = Join-Path $CloneHome $sub
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
}
Write-Host " $CloneHome"
# Running guard: the per-clone single-instance lock keys off the clone's own
# userData, so launching a RUNNING clone just focuses it - inform, not block.
if (Test-LockfileHeld $Lockfile) {
Write-Warning "'$Instance' appears to be RUNNING (lockfile held) - this launch will focus it instead of starting a new copy."
exit 0
}
# Identity marker + icon (consumed by the patched AUMID wrap and the
# browser-window-created window stamper; rewritten fresh on every launch).
Write-Host "[3/5] Writing clone identity marker..."
$iconPath = Join-Path $DesktopDir '.opencode-clone-icon.ico'
$identity = [ordered]@{
aumidSuffix = $InstanceDefs[$Instance].AumidSuffix
title = "OpenCode " + $InstanceDefs[$Instance].Color
}
if (Test-Path (Join-Path $BrandingDir 'tray_icon.ico')) {
Copy-Item (Join-Path $BrandingDir 'tray_icon.ico') $iconPath -Force
if (-not (Test-Path $iconPath)) { throw "icon copy FAILED: $iconPath" }
$identity.iconPath = $iconPath
} else {
Write-Warning ("Branding not found at {0} - window icon degraded (taskbar identity still applies; drop tray_icon.ico there and relaunch)." -f $BrandingDir)
}
$identity | ConvertTo-Json -Depth 3 | Set-Content -Path $IdentityJson -Encoding UTF8
$verify = Get-Content -LiteralPath $IdentityJson -Raw | ConvertFrom-Json
if ($verify.aumidSuffix -ne $InstanceDefs[$Instance].AumidSuffix) { throw "identity marker verify FAILED: re-read mismatch" }
Write-Host " aumidSuffix=$($verify.aumidSuffix) title='$($verify.title)'"
# --- Launch --------------------------------------------------------------------
# Start-Process is detached and env-inheriting; the app is asInvoker so the
# argv flags (the isolation channel) survive untouched. Identity rides argv
# too (engine v2: the app's main bundle is ESM - no require - so the
# injected resolver reads ONLY argv). Each flag is quoted as ONE argument so
# spaced values stay single argv entries; the injected parser strips quotes.
Write-Host "[4/5] Launching OpenCode clone '$Instance' ..."
$installDir = Split-Path $ExePath -Parent
$launchArgs = @(
"`"--opencode-clone-home=$CloneHome`"",
"`"--opencode-clone-id=$($InstanceDefs[$Instance].AumidSuffix)`""
)
if ($identity.title) { $launchArgs += "`"--opencode-clone-title=$($identity.title)`"" }
if ($identity.iconPath) { $launchArgs += "`"--opencode-clone-icon=$($identity.iconPath)`"" }
$proc = Start-Process -FilePath $ExePath -WorkingDirectory $installDir -ArgumentList $launchArgs -PassThru
Write-Host (" started pid {0}" -f $proc.Id)
Write-Host "[5/5] Verify-after-launch (up to 60s)..."
$deadline = (Get-Date).AddSeconds(60)
$lockSeen = $false
$procSeen = $false
$cmdlReadable = $true
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 2
if (-not $lockSeen -and (Test-LockfileHeld $Lockfile)) { $lockSeen = $true }
$pi = Get-CloneProcess
if (-not $pi.CommandLineReadable) { $cmdlReadable = $false }
if ($pi.Matched.Count -gt 0) { $procSeen = $true; break }
if ($lockSeen -and $cmdlReadable) { break }
}
$ok = $false
if ($procSeen) {
$ok = $true
Write-Host (" verified: {0} process(es) carry this clone's flag; lockfile HELD." -f $pi.Matched.Count)
} elseif ($lockSeen) {
# Command lines unreadable (AutoClaw PEB-scrub precedent) - the clone's own
# lockfile being held inside ITS home is decisive evidence the redirect engaged.
$ok = $true
Write-Host ' verified via the clone lockfile (command lines unreadable - WMI limitation, flag match unavailable).'
} else {
Write-Warning ' NOT verified within 60s: no flag-matched process and no lockfile. Check whether an OpenCode window opened; if it is the PRIMARY profile, close it and re-run the patch before launching clones.'
}
if (-not $ok) { exit 1 }
Write-Host ""
Write-Host "launched. A fresh clone needs ONE manual sign-in (never automated)." -ForegroundColor Green