diff --git a/src/data.js b/src/data.js index 557d875..4c931c4 100644 --- a/src/data.js +++ b/src/data.js @@ -66,10 +66,10 @@ const EXTRA_OPENCODE_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', ' const EXTRA_KIRO_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Roaming', 'kiro-cli', 'data.sqlite3')).filter(d => fs.existsSync(d)); if (IS_WSL) { - console.log(' \x1b[36m[WSL]\x1b[0m Detected Windows homes:', ALL_HOMES.slice(1).join(', ')); - if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); - if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); - if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); + console.log(' \x1b[36m[WSL]\x1b[0m Also scanning Windows host homes:', ALL_HOMES.slice(1).join(', ')); + if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); + if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); + if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); } // ── Helpers ──────────────────────────────────────────────── diff --git a/src/frontend/app.js b/src/frontend/app.js index 9e6ce60..6534679 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1575,7 +1575,7 @@ function focusSession(sessionId) { fetch('/api/focus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pid: a.pid }) + body: JSON.stringify({ pid: a.pid, sessionId: sessionId }) }).then(function(r) { return r.json(); }).then(function(data) { if (data.ok) { var hint = data.terminal || 'terminal'; diff --git a/src/server.js b/src/server.js index 1fd0ff8..41c9a53 100644 --- a/src/server.js +++ b/src/server.js @@ -2,9 +2,9 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); -const { exec, execFile } = require('child_process'); +const { exec, execFile, execFileSync } = require('child_process'); const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo, getLeaderboardStats } = require('./data'); -const { detectTerminals, openInTerminal, focusTerminalByPid } = require('./terminals'); +const { detectTerminals, openInTerminal, focusTerminalByPid, isWSL } = require('./terminals'); const { convertSession } = require('./convert'); const { generateHandoff } = require('./handoff'); const { CHANGELOG } = require('./changelog'); @@ -110,6 +110,9 @@ function startServer(host, port, openBrowser = true) { readBody(req, body => { try { const { sessionId, tool, flags, project, terminal } = JSON.parse(body); + if (!/^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || ''))) { + throw new Error('invalid sessionId'); + } log('LAUNCH', `session=${sessionId} tool=${tool || 'claude'} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${(flags || []).join(',') || '(none)'}`); openInTerminal(sessionId, tool || 'claude', flags || [], project || '', terminal || ''); log('LAUNCH', 'ok'); @@ -198,11 +201,7 @@ function startServer(host, port, openBrowser = true) { target = require('path').dirname(target); } log('IDE', `ide=${ide} project=${project} target=${target}`); - if (ide === 'cursor') { - exec(`cursor "${target || '.'}"`); - } else if (ide === 'code') { - exec(`code "${target || '.'}"`); - } + openIDE(ide, target || '.'); json(res, { ok: true }); } catch (e) { json(res, { ok: false, error: e.message }, 400); @@ -244,9 +243,15 @@ function startServer(host, port, openBrowser = true) { else if (req.method === 'POST' && pathname === '/api/focus') { readBody(req, body => { try { - const { pid } = JSON.parse(body); - log('FOCUS', `pid=${pid}`); - const result = focusTerminalByPid(pid); + const { pid, sessionId } = JSON.parse(body); + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error('invalid pid'); + } + if (sessionId && !/^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId))) { + throw new Error('invalid sessionId'); + } + log('FOCUS', `pid=${pid} sessionId=${sessionId || '(none)'}`); + const result = focusTerminalByPid(pid, sessionId); log('FOCUS', `result: terminal=${result.terminal || 'none'} ok=${result.ok}`); json(res, result); } catch (e) { @@ -441,8 +446,14 @@ function startServer(host, port, openBrowser = true) { if (openBrowser) { if (process.platform === 'darwin') { execFile('open', [browserUrl]); - } else if (process.platform === 'linux') { + } else if (process.platform === 'linux' && !isWSL()) { execFile('xdg-open', [browserUrl]); + } else if (isWSL()) { + // In WSL the browser lives on the Windows host. xdg-open inside WSL + // typically fails or opens a Linux-side browser that nobody is looking + // at. Print the URL and let the user click it from Windows. + console.log(' \x1b[33mWSL detected — open this URL in your Windows browser:\x1b[0m'); + console.log(` \x1b[36m${browserUrl}\x1b[0m`); } } @@ -453,6 +464,57 @@ function startServer(host, port, openBrowser = true) { }); } +function openIDE(ide, target) { + const bin = ide === 'cursor' ? 'cursor' : 'code'; + const winBin = bin + '.exe'; + const runLog = (err) => { if (err) log('ERROR', `${ide} open failed: ${err.message}`); }; + + if (!isWSL()) { + // execFile with argv — a project path containing quotes or spaces must not + // get re-parsed by /bin/sh. + execFile(bin, [target], runLog); + return; + } + + // WSL: branch on whether the project lives on the Windows side or inside WSL. + const isWinSide = /^[A-Za-z]:[\\/]/.test(target) || target.includes('\\') || /^\/mnt\/[a-z]\//i.test(target); + + if (isWinSide) { + // Translate /mnt/c/... back to C:\... and open natively on Windows. + let winTarget = target; + const m = target.match(/^\/mnt\/([a-z])\/(.*)$/i); + if (m) winTarget = m[1].toUpperCase() + ':\\' + m[2].replace(/\//g, '\\'); + execFile(winBin, [winTarget], runLog); + return; + } + + // WSL-side project: prefer the Linux wrapper installed by the Remote-WSL + // extension since it handles path translation. Probe via execFileSync('which') + // so a missing import would throw loudly instead of being swallowed. + let hasWrapper = false; + try { + execFileSync('which', [bin], { stdio: 'pipe' }); + hasWrapper = true; + } catch (e) { + if (e.code !== 1 && !/not found|No such/.test(e.message || '')) { + log('WARN', `which ${bin} probe error: ${e.message}`); + } + } + + if (hasWrapper) { + execFile(bin, [target], runLog); + return; + } + + const distro = process.env.WSL_DISTRO_NAME || ''; + if (!distro) { + log('WARN', `openIDE: no WSL_DISTRO_NAME, cannot build --remote URI for ${winBin}`); + execFile(winBin, [target], runLog); + return; + } + execFile(winBin, ['--remote', `wsl+${distro}`, target], runLog); +} + function sendHeartbeat() { try { const { getOrCreateAnonId } = require('./data'); diff --git a/src/terminals.js b/src/terminals.js index 75286b7..cfd3c87 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -3,7 +3,15 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execSync, exec } = require('child_process'); +const { execSync, execFileSync, exec, execFile } = require('child_process'); + +function execFileSafe(cmd, args) { + // Fire-and-forget: wt.exe / powershell.exe detach immediately, we only care + // that the Windows process was kicked off. Errors are logged, never thrown. + execFile(cmd, args, (err) => { + if (err) termLog('ERROR', `${cmd} failed: ${err.message}`); + }); +} // Run cmux CLI command via osascript — needed because codedash runs as a detached server // and cmux rejects direct socket connections from processes not inside a cmux terminal @@ -12,6 +20,83 @@ function cmuxExec(args) { return execSync(`osascript -e 'do shell script "cmux ${escaped}"'`, { encoding: 'utf8', timeout: 5000 }).trim(); } +// ── WSL detection ─────────────────────────────────────────── + +let _wslCache = null; +function isWSL() { + if (_wslCache !== null) return _wslCache; + if (process.platform !== 'linux') { _wslCache = false; return false; } + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { _wslCache = true; return true; } + try { + const v = fs.readFileSync('/proc/version', 'utf8'); + _wslCache = /microsoft|WSL/i.test(v); + } catch { _wslCache = false; } + return _wslCache; +} + +function wslDistro() { + return process.env.WSL_DISTRO_NAME || ''; +} + +// Accept only conservative ID characters. Used for anything that flows into a +// filename, window title or resume command argument. All five supported agents +// use IDs that fit this regex (UUIDs, slugs, integers). +const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; + +function assertSafeSessionId(sessionId) { + if (!SAFE_SESSION_ID.test(String(sessionId || ''))) { + throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`); + } +} + +// Tag used as top-level window title so focusTerminalByPid can locate the +// window later via EnumWindows. +function sessionTag(sessionId) { + return 'codedash-' + String(sessionId).slice(0, 12); +} + +// A session is "Windows-side" if its cwd looks like a Windows path +// (drive letter or backslashes) or lives under /mnt//... from WSL. +function isWindowsSidePath(p) { + if (!p) return false; + if (/^[A-Za-z]:[\\/]/.test(p)) return true; + if (p.includes('\\')) return true; + if (/^\/mnt\/[a-z]\//i.test(p)) return true; + return false; +} + +// Translate /mnt/c/Users/foo → C:\Users\foo. Windows-format paths pass through. +function toWindowsPath(p) { + if (!p) return ''; + if (/^[A-Za-z]:[\\/]/.test(p) || p.includes('\\')) return p.replace(/\//g, '\\'); + const m = p.match(/^\/mnt\/([a-z])\/(.*)$/i); + if (m) return m[1].toUpperCase() + ':\\' + m[2].replace(/\//g, '\\'); + try { + return execSync(`wslpath -w ${JSON.stringify(p)}`, { encoding: 'utf8', timeout: 2000 }).trim(); + } catch { + return p; + } +} + +// Write the resume command to a .sh file in a private temp directory to avoid +// nested quoting through wt.exe / cmd.exe / powershell.exe / wsl.exe / bash, +// and to isolate each launch from client-controlled filename tricks. +function writeWslLaunchScript(fullCmd) { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codedash-launch-')); + const file = path.join(baseDir, 'launch.sh'); + const body = [ + '#!/bin/bash -l', + '# Auto-generated by codedash — safe to delete', + fullCmd, + 'echo', + 'echo "[codedash] session finished — press Enter to close, or keep this shell."', + 'exec bash -l', + '', + ].join('\n'); + fs.writeFileSync(file, body, { mode: 0o755 }); + return { file, dir: baseDir }; +} + // ── Detect available terminals ────────────────────────────── function detectTerminals() { @@ -51,6 +136,21 @@ function detectTerminals() { terminals.push({ id: 'cmux', name: 'cmux', available: true }); } } catch {} + } else if (platform === 'linux' && isWSL()) { + // WSL: launch Windows-side terminals via wsl.exe so the UI in the Windows + // browser can spawn a new console on the host and keep the CLI agent in WSL. + const wslTerms = [ + { id: 'wsl-windows-terminal', name: 'Windows Terminal (wt.exe)', cmd: 'wt.exe' }, + { id: 'wsl-powershell', name: 'PowerShell (powershell.exe)', cmd: 'powershell.exe' }, + ]; + for (const t of wslTerms) { + try { + execSync(`which ${t.cmd}`, { stdio: 'pipe' }); + terminals.push({ id: t.id, name: t.name, available: true }); + } catch { + terminals.push({ id: t.id, name: t.name, available: false }); + } + } } else if (platform === 'linux') { const linuxTerms = [ { id: 'gnome-terminal', name: 'GNOME Terminal', cmd: 'gnome-terminal' }, @@ -181,6 +281,78 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { break; } } + } else if (platform === 'linux' && isWSL()) { + assertSafeSessionId(sessionId); + const tag = sessionTag(sessionId); + const tid = terminalId || 'wsl-windows-terminal'; + const winSide = isWindowsSidePath(projectDir); + termLog('TERM', `WSL launch: winSide=${winSide} terminal=${tid}`); + + if (winSide) { + // Session lives on the Windows side — run claude/codex natively on + // Windows without bouncing through wsl.exe. Convert /mnt/c/... paths + // back to C:\ so wt.exe can cd into the project. + const winProjectDir = toWindowsPath(projectDir); + if (tid === 'wsl-powershell') { + const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; Set-Location -LiteralPath '${(winProjectDir || '.').replace(/'/g, "''")}'; ${cmd}`; + // execFileSync with an argv array keeps /bin/sh out of the loop so + // PowerShell variables like $Host survive until the child shell. + execFileSync('powershell.exe', [ + '-NoProfile', + '-Command', + 'Start-Process', + 'powershell', + '-ArgumentList', + `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, + ], { stdio: 'pipe' }); + } else { + // `-w 0` targets the current/MRU window so the session opens as a + // new tab instead of a new window. wt falls back to creating a + // fresh window if none exists, so no special handling needed. + // Focus later selects the right tab via UIA, not MainWindowTitle. + const args = ['-w', '0', 'new-tab', '--title', tag, '--suppressApplicationTitle']; + if (winProjectDir) { args.push('--startingDirectory', winProjectDir); } + args.push('cmd.exe', '/k', cmd); + execFileSafe('wt.exe', args); + } + return; + } + + // WSL-side session: wrap cd + resume in a .sh so nested quoting through + // wt.exe / powershell.exe / wsl.exe / bash stays safe. + const { file: scriptPath, dir: scriptDir } = writeWslLaunchScript(fullCmd); + const distro = wslDistro(); + const distroArg = distro ? ` -d '${distro.replace(/'/g, "''")}'` : ''; + + if (tid === 'wsl-powershell') { + const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash -li ${scriptPath.replace(/'/g, "''")}`; + execFileSync('powershell.exe', [ + '-NoProfile', + '-Command', + 'Start-Process', + 'powershell', + '-ArgumentList', + `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, + ], { stdio: 'pipe' }); + } else { + // `-w 0` — see comment above; reuse the current wt window as a new + // tab and let UIA-based focus pick the right one later. + const args = [ + '-w', '0', + 'new-tab', + '--title', tag, + '--suppressApplicationTitle', + 'wsl.exe', + ]; + if (distro) { args.push('-d', distro); } + args.push('--', 'bash', '-li', scriptPath); + execFileSafe('wt.exe', args); + } + // bash reads the script on startup; 60 s is plenty. fs.rm with recursive + // wipes the mkdtemp dir, not just the file. + setTimeout(() => { + try { fs.rmSync(scriptDir, { recursive: true, force: true }); } catch {} + }, 60000); } else if (platform === 'linux') { switch (terminalId) { case 'kitty': @@ -233,11 +405,355 @@ function focusCmuxWorkspace(pid) { return { ok: true, terminal: 'cmux' }; } +// ── WSL: focus a Windows Terminal tab hosting a Linux pid ─── +// +// Strategy (established empirically after ruling out title tagging, WT_SESSION +// targeting via `wt.exe -w 0`, and PEB env reading): +// +// 1. Resolve the pts the target process is bound to via /proc//fd/*. +// 2. Ask PowerShell to snapshot every wt TabItem (keyed by window hwnd and +// positional index) through UI Automation — wt exposes them even when the +// tab isn't active. We avoid UIA RuntimeId because MSDN explicitly warns +// it's not persistent between calls, and a second PS invocation re-issues +// fresh ids for the same tab. +// 3. Write an OSC 2 escape (`\e]2;\a`) straight into the pts; wt +// renders it via the master side and the tab's UIA Name updates almost +// instantly. +// 4. Ask PowerShell to poll UIA for a TabItem whose Name == marker, Select() +// it via the SelectionItem pattern, and SetForegroundWindow on the parent +// wt window (handling IsIconic → ShowWindowAsync restore). +// 5. Look up the matched tab's (hwnd, index) in the snapshot, write the +// original Name back through OSC 2 so the marker doesn't linger in the UI. +// +// Fallbacks: if the target process isn't attached to a pts, or the marker +// never appears in UIA (e.g., wt not running, app rewrites the title too +// quickly), we return a no-op result so the UI shows the "click manually" +// hint. + +function ptsForPid(pid) { + for (const fd of [0, 1, 2]) { + try { + const link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`); + if (link && link.startsWith('/dev/pts/')) return link; + } catch {} + } + return ''; +} + +// Windows Terminal injects WT_SESSION into each tab's env; WSL forwards it +// via WSLENV=WT_SESSION:WT_PROFILE_ID:..., so it lands in /proc//environ +// for anything launched from a WT tab. Absence means the process is in +// conhost/OpenConsole (PowerShell window, etc.), which does NOT render as a +// WT tab — using OSC 2 would mutate the real conhost title and break the +// title-based fallback. +function isWtSession(pid) { + try { + const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8'); + return env.split('\0').some((e) => e.startsWith('WT_SESSION=')); + } catch { + return false; + } +} + +// PS script: one-shot snapshot of every wt TabItem's position and Name. +// Key is (windowHwnd, tabIndex) because UIA RuntimeId is not guaranteed +// stable across PS invocations, whereas tab index within a window is stable +// as long as the user doesn't reorder tabs (and OSC title writes don't). +// Emits pipe-delimited lines so we don't need ConvertFrom-Json on the Node +// side and PS's default JSON escaping doesn't fight with tab titles that +// contain quotes. +const PS_WT_SNAPSHOT = [ + 'Add-Type -AssemblyName UIAutomationClient', + 'Add-Type -AssemblyName UIAutomationTypes', + '$wt = Get-Process WindowsTerminal -ErrorAction SilentlyContinue | Select-Object -First 1', + "if (-not $wt) { Write-Output 'NOWT'; exit 0 }", + '$root = [System.Windows.Automation.AutomationElement]::RootElement', + '$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $wt.Id)', + '$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)', + '$tabCond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::TabItem)', + 'foreach ($w in $wins) {', + ' $hwnd = $w.Current.NativeWindowHandle', + ' $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond)', + ' for ($i = 0; $i -lt $tabs.Count; $i++) {', + ' $t = $tabs.Item($i)', + ' $nameB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($t.Current.Name))', + ' Write-Output "TAB|$hwnd|$i|$nameB64"', + ' }', + '}', + "Write-Output 'END'", + '', +].join('\r\n'); + +// PS script: poll UIA for a TabItem whose Name == marker, select it, raise +// the parent wt window. Outputs "OK||" on success, "NOTFOUND" +// on timeout. Deliberately separate from the snapshot script so the snapshot +// observes the *original* names before we mutate them via OSC. We scan tabs +// positionally (via Item(i)) so we can report the exact index, which is the +// same key the snapshot records. +const PS_WT_FIND_SELECT = [ + 'param([Parameter(Mandatory=$true)][string]$Marker)', + 'Add-Type -AssemblyName UIAutomationClient', + 'Add-Type -AssemblyName UIAutomationTypes', + '$sig = @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public class CodedashFg {', + ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);', + ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int n);', + ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);', + '}', + '"@', + 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue', + '$wt = Get-Process WindowsTerminal -ErrorAction SilentlyContinue | Select-Object -First 1', + "if (-not $wt) { Write-Output 'NOWT'; exit 0 }", + '$root = [System.Windows.Automation.AutomationElement]::RootElement', + '$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $wt.Id)', + '$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)', + '$tabCond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::TabItem)', + '$deadline = (Get-Date).AddMilliseconds(2000)', + 'while ((Get-Date) -lt $deadline) {', + ' foreach ($w in $wins) {', + ' $hwnd = $w.Current.NativeWindowHandle', + ' $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond)', + ' for ($i = 0; $i -lt $tabs.Count; $i++) {', + ' $t = $tabs.Item($i)', + ' if ($t.Current.Name -eq $Marker) {', + ' try {', + ' $si = $t.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)', + ' $si.Select()', + ' } catch {}', + ' $h = [IntPtr]$hwnd', + ' if ($h -ne [IntPtr]::Zero) {', + ' if ([CodedashFg]::IsIconic($h)) { [CodedashFg]::ShowWindowAsync($h, 9) | Out-Null }', + ' [CodedashFg]::SetForegroundWindow($h) | Out-Null', + ' }', + ' Write-Output "OK|$hwnd|$i"', + ' exit 0', + ' }', + ' }', + ' }', + ' Start-Sleep -Milliseconds 75', + '}', + "Write-Output 'NOTFOUND'", + '', +].join('\r\n'); + +// PS script: fallback for non-WindowsTerminal processes (powershell.exe, +// conhost/OpenConsole). The launch path pins `$Host.UI.RawUI.WindowTitle` to +// the sessionTag, so an exact MainWindowTitle match is reliable. We exclude +// WindowsTerminal on purpose: WT only exposes the currently active tab via +// MainWindowTitle, so matching it here could focus the wrong tab — the UIA +// path above is the authoritative one for WT. +const PS_WINDOW_BY_TITLE = [ + 'param([Parameter(Mandatory=$true)][string]$Tag)', + '$sig = @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public class CodedashWin {', + ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);', + ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int n);', + ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);', + '}', + '"@', + 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue', + '$proc = Get-Process | Where-Object { $_.ProcessName -ne "WindowsTerminal" -and $_.MainWindowTitle -and $_.MainWindowTitle -eq $Tag } | Select-Object -First 1', + 'if ($proc) {', + ' $h = $proc.MainWindowHandle', + ' if ([CodedashWin]::IsIconic($h)) { [CodedashWin]::ShowWindowAsync($h, 9) | Out-Null }', + ' [CodedashWin]::SetForegroundWindow($h) | Out-Null', + " Write-Output 'OK'", + '} else {', + " Write-Output 'NOTFOUND'", + '}', + '', +].join('\r\n'); + +let _ps1SnapshotPath = null; +let _ps1FindPath = null; +let _ps1WindowByTitlePath = null; +function ensureFocusScripts() { + // Reuse a single set of .ps1 files per process — avoids hundreds of stale + // temp files if the user hammers the Focus button. + if (!_ps1SnapshotPath) { + _ps1SnapshotPath = path.join(os.tmpdir(), `codedash-wt-snapshot-${process.pid}.ps1`); + fs.writeFileSync(_ps1SnapshotPath, PS_WT_SNAPSHOT); + } + if (!_ps1FindPath) { + _ps1FindPath = path.join(os.tmpdir(), `codedash-wt-find-${process.pid}.ps1`); + fs.writeFileSync(_ps1FindPath, PS_WT_FIND_SELECT); + } + if (!_ps1WindowByTitlePath) { + _ps1WindowByTitlePath = path.join(os.tmpdir(), `codedash-win-by-title-${process.pid}.ps1`); + fs.writeFileSync(_ps1WindowByTitlePath, PS_WINDOW_BY_TITLE); + } + return { snapshot: _ps1SnapshotPath, find: _ps1FindPath, windowByTitle: _ps1WindowByTitlePath }; +} + +function writeOscTitle(ttyPath, title) { + // OSC 2 (\e]2;\a) sets the window/tab title. Writing to the slave + // pts from outside the app flows through to the master side and wt picks + // it up as if the app itself had emitted it. + const fd = fs.openSync(ttyPath, 'w'); + try { + fs.writeSync(fd, `\x1b]2;${title}\x07`); + } finally { + fs.closeSync(fd); + } +} + +// Attempt the Windows Terminal UIA + OSC marker path for the given pts. +// Returns the same { ok, terminal, error } shape as focusWslByPid. +function focusWtTabByMarker(tty, scripts) { + // Snapshot first so we can restore the original tab title afterwards. + // Key format: "<hwnd>|<tabIndex>" — matches what the find script reports. + const snapshotMap = new Map(); + try { + const out = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scripts.snapshot], + { encoding: 'utf8', timeout: 5000 } + ); + for (const line of out.split(/\r?\n/)) { + if (!line.startsWith('TAB|')) continue; + const parts = line.split('|'); + if (parts.length < 4) continue; + const hwnd = parts[1]; + const idx = parts[2]; + const nameB64 = parts.slice(3).join('|'); + const name = Buffer.from(nameB64, 'base64').toString('utf8'); + snapshotMap.set(`${hwnd}|${idx}`, name); + } + termLog('FOCUS', `wt snapshot: ${snapshotMap.size} tab(s)`); + } catch (e) { + termLog('ERROR', `wt snapshot failed: ${e.message}`); + return { ok: false, error: 'wt snapshot failed: ' + e.message }; + } + + if (snapshotMap.size === 0) { + return { ok: false, error: 'No Windows Terminal tabs visible — is wt running?' }; + } + + // Inject marker into the pts so the target tab renames itself. + const marker = `codedash-focus-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + try { + writeOscTitle(tty, marker); + } catch (e) { + termLog('ERROR', `osc write to ${tty} failed: ${e.message}`); + return { ok: false, error: 'Could not write marker to pts: ' + e.message }; + } + + let matchedKey = ''; + try { + const out = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scripts.find, '-Marker', marker], + { encoding: 'utf8', timeout: 6000 } + ).trim(); + termLog('FOCUS', `wt find: ${out}`); + if (out.startsWith('OK|')) { + matchedKey = out.slice(3); // "<hwnd>|<tabIndex>" + } else { + // Best-effort title cleanup — the marker is still on the tab, so rewrite + // it to empty and let claude/codex repaint on the next update. + try { writeOscTitle(tty, ''); } catch {} + return { ok: false, error: 'wt tab not found' }; + } + } catch (e) { + termLog('ERROR', `wt find failed: ${e.message}`); + try { writeOscTitle(tty, ''); } catch {} + return { ok: false, error: e.message }; + } + + // Restore the tab's pre-marker title. A missing entry means the tab was + // created between snapshot and find — fall back to blank so the next agent + // paint wins. + const origName = snapshotMap.get(matchedKey); + termLog('FOCUS', `restore key=${matchedKey} name=${JSON.stringify(origName)}`); + try { + writeOscTitle(tty, origName != null ? origName : ''); + } catch (e) { + termLog('ERROR', `title restore failed: ${e.message}`); + } + + return { ok: true, terminal: 'Windows Terminal' }; +} + +// Attempt the non-WT fallback: locate a top-level window whose +// MainWindowTitle exactly equals the sessionTag. This covers the +// `wsl-powershell` launch path, which pins the console window title at +// startup. WindowsTerminal is excluded inside the PS script. +function focusWindowBySessionTag(sessionId, scripts) { + try { + assertSafeSessionId(sessionId); + } catch (e) { + return { ok: false, error: e.message }; + } + const tag = sessionTag(sessionId); + try { + const out = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scripts.windowByTitle, '-Tag', tag], + { encoding: 'utf8', timeout: 5000 } + ).trim(); + termLog('FOCUS', `wsl title fallback: ${out}`); + if (out.endsWith('OK')) return { ok: true, terminal: 'PowerShell' }; + return { ok: false, error: 'Could not focus — try clicking the terminal manually' }; + } catch (e) { + termLog('ERROR', `wsl title fallback failed: ${e.message}`); + return { ok: false, error: e.message }; + } +} + +function focusWslByPid(pid, sessionId) { + // Defense in depth: /api/focus also validates, but focusWslByPid feeds pid + // directly into /proc/<pid>/fd/<n>, so re-check before touching the path. + if (!Number.isInteger(pid) || pid <= 0) { + return { ok: false, error: 'invalid pid' }; + } + + let scripts; + try { + scripts = ensureFocusScripts(); + } catch (e) { + termLog('ERROR', `ensureFocusScripts: ${e.message}`); + return { ok: false, error: e.message }; + } + + // WT_SESSION in /proc/<pid>/environ tells us whether the target actually + // lives inside a Windows Terminal tab. Gating the WT marker path on this + // flag is critical: conhost honors OSC 2, so writing the marker into a + // non-WT pts would overwrite the console's real title and break the + // sessionTag-based fallback further down. + const tty = ptsForPid(pid); + const inWt = tty ? isWtSession(pid) : false; + termLog('FOCUS', `wsl pid ${pid} pts=${tty || '(none)'} inWt=${inWt}`); + + if (inWt) { + const wtResult = focusWtTabByMarker(tty, scripts); + if (wtResult.ok) return wtResult; + termLog('FOCUS', `wt path failed, trying title fallback: ${wtResult.error || ''}`); + } + + // Fallback for `wsl-powershell` launches (conhost/powershell.exe windows) + // that pin MainWindowTitle to sessionTag at startup. Also catches the edge + // case where WT path missed after pts mutation — title may now be blank, + // so this will just return NOTFOUND without doing harm. + if (sessionId) { + return focusWindowBySessionTag(sessionId, scripts); + } + + return { ok: false, error: 'Could not focus — try clicking the terminal manually' }; +} + // ── Focus existing terminal by PID ────────────────────────── -function focusTerminalByPid(pid) { +function focusTerminalByPid(pid, sessionId) { const platform = process.platform; - termLog('FOCUS', `focusTerminalByPid: pid=${pid} platform=${platform}`); + termLog('FOCUS', `focusTerminalByPid: pid=${pid} sessionId=${sessionId || '(none)'} platform=${platform}`); + + if (platform === 'linux' && isWSL()) { + return focusWslByPid(pid, sessionId); + } if (platform === 'darwin') { // Find which terminal app owns this PID's TTY, then activate it @@ -361,4 +877,4 @@ function focusTerminalByPid(pid) { return { ok: false }; } -module.exports = { detectTerminals, openInTerminal, focusTerminalByPid }; +module.exports = { detectTerminals, openInTerminal, focusTerminalByPid, isWSL };