From 4093b27aac09cd8689f984eb58d3da8db6e4964e Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Sat, 11 Apr 2026 19:01:14 +0300 Subject: [PATCH 1/7] feat(wsl): launch, focus and IDE open from Windows host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class support for running codedash inside WSL while using the Windows browser as UI: - terminals.js: detect WSL via /proc/version + WSL_DISTRO_NAME; expose wt.exe / powershell.exe as launch targets. Open new terminals through a .sh trampoline to sidestep nested quoting across wt → cmd → wsl → bash. Tag each tab/window title with codedash- so focus can find it later. - terminals.js: focusTerminalByPid accepts sessionId and, on WSL, runs a temp .ps1 that enumerates processes by MainWindowTitle and calls SetForegroundWindow (ShowWindowAsync for minimized windows). - terminals.js: handle Windows-side sessions (cwd under /mnt/ or in Windows path form) by launching natively via cmd.exe or powershell instead of wsl.exe, with path translation back to C:\\. - server.js: skip xdg-open in WSL and print the URL for the user to open on the Windows host; route Focus via sessionId; openIDE helper branches between Linux wrapper, code.exe --remote wsl+ and native Windows invocation based on path origin. - frontend/app.js: send sessionId alongside pid in /api/focus. - data.js: clearer log labels for Windows-host scan directories. --- src/data.js | 8 +- src/frontend/app.js | 2 +- src/server.js | 70 +++++++++++++--- src/terminals.js | 191 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 252 insertions(+), 19 deletions(-) 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..178242f 100644 --- a/src/server.js +++ b/src/server.js @@ -4,7 +4,7 @@ const https = require('https'); const { URL } = require('url'); const { exec, execFile } = 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'); @@ -198,11 +198,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 +240,9 @@ 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); + 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 +437,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 +455,54 @@ function startServer(host, port, openBrowser = true) { }); } +function openIDE(ide, target) { + const bin = ide === 'cursor' ? 'cursor' : 'code'; + const winBin = bin + '.exe'; + + if (!isWSL()) { + exec(`${bin} "${target}"`); + 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], (err) => { + if (err) log('ERROR', `${winBin} failed: ${err.message}`); + }); + return; + } + + // WSL-side project: prefer the Linux wrapper installed by the Remote-WSL + // extension since it handles path translation. Fall back to .exe with + // --remote wsl+. + let hasWrapper = false; + try { + execSync(`which ${bin}`, { stdio: 'pipe' }); + hasWrapper = true; + } catch {} + + if (hasWrapper) { + exec(`${bin} "${target}"`); + return; + } + + const distro = process.env.WSL_DISTRO_NAME || ''; + if (!distro) { + log('WARN', `openIDE: no WSL_DISTRO_NAME, cannot build --remote URI for ${winBin}`); + exec(`${winBin} "${target}"`); + return; + } + execFile(winBin, ['--remote', `wsl+${distro}`, target], (err) => { + if (err) log('ERROR', `${winBin} failed: ${err.message}`); + }); +} + function sendHeartbeat() { try { const { getOrCreateAnonId } = require('./data'); diff --git a/src/terminals.js b/src/terminals.js index 75286b7..a30b37c 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,70 @@ 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 || ''; +} + +// Tag used as tab/window title so focusTerminalByPid can locate the window later. +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 temp .sh file to avoid nested quoting through +// wt.exe / cmd.exe / powershell.exe / wsl.exe / bash. +function writeWslLaunchScript(sessionId, fullCmd) { + const dir = os.tmpdir(); + const file = path.join(dir, `codedash-launch-${sessionId || Date.now()}.sh`); + const body = [ + '#!/bin/bash', + '# 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; +} + // ── Detect available terminals ────────────────────────────── function detectTerminals() { @@ -51,6 +123,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 +268,53 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { break; } } + } else if (platform === 'linux' && isWSL()) { + 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}`; + const psCmd = `Start-Process powershell -ArgumentList '-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`; + execSync(`powershell.exe -NoProfile -Command ${JSON.stringify(psCmd)}`, { stdio: 'pipe' }); + } else { + 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 scriptPath = writeWslLaunchScript(sessionId, fullCmd); + const distro = wslDistro(); + const distroArg = distro ? ` -d ${JSON.stringify(distro)}` : ''; + + if (tid === 'wsl-powershell') { + const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash ${scriptPath.replace(/'/g, "''")}`; + const psCmd = `Start-Process powershell -ArgumentList '-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`; + execSync(`powershell.exe -NoProfile -Command ${JSON.stringify(psCmd)}`, { stdio: 'pipe' }); + } else { + const args = [ + '-w', '0', + 'new-tab', + '--title', tag, + '--suppressApplicationTitle', + 'wsl.exe', + ]; + if (distro) { args.push('-d', distro); } + args.push('--', 'bash', scriptPath); + execFileSafe('wt.exe', args); + } + setTimeout(() => { try { fs.unlinkSync(scriptPath); } catch {} }, 60000); } else if (platform === 'linux') { switch (terminalId) { case 'kitty': @@ -235,9 +369,58 @@ function focusCmuxWorkspace(pid) { // ── 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()) { + // On WSL we launched terminals with a window/tab title tag tied to the + // session id. Enumerate Windows processes and SetForegroundWindow on the + // first match whose MainWindowTitle contains the tag. + if (!sessionId) return { ok: false, error: 'WSL focus requires sessionId' }; + const tag = sessionTag(sessionId); + // Write a .ps1 script to a temp file — Node's exec runs through /bin/sh, + // which would otherwise expand PowerShell variables like $p and $t. + const psBody = [ + '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 { $_.MainWindowTitle -and $_.MainWindowTitle -like "*$Tag*" } | Select-Object -First 1', + 'if ($proc) {', + ' if ([CodedashWin]::IsIconic($proc.MainWindowHandle)) { [CodedashWin]::ShowWindowAsync($proc.MainWindowHandle, 9) | Out-Null }', + ' [CodedashWin]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null', + " Write-Output 'ok'", + '} else {', + " Write-Output 'notfound'", + '}', + '', + ].join('\r\n'); + const ps1Path = path.join(os.tmpdir(), `codedash-focus-${Date.now()}.ps1`); + try { + fs.writeFileSync(ps1Path, psBody); + const out = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps1Path, '-Tag', tag], + { encoding: 'utf8', timeout: 5000 } + ).trim(); + termLog('FOCUS', `wsl result: ${out}`); + if (out.endsWith('ok')) return { ok: true, terminal: 'Windows Terminal / PowerShell' }; + return { ok: false, error: 'Window not found — launch the session from UI first' }; + } catch (e) { + termLog('ERROR', `wsl focus failed: ${e.message}`); + return { ok: false, error: e.message }; + } finally { + try { fs.unlinkSync(ps1Path); } catch {} + } + } if (platform === 'darwin') { // Find which terminal app owns this PID's TTY, then activate it @@ -361,4 +544,4 @@ function focusTerminalByPid(pid) { return { ok: false }; } -module.exports = { detectTerminals, openInTerminal, focusTerminalByPid }; +module.exports = { detectTerminals, openInTerminal, focusTerminalByPid, isWSL }; From 9ba3d5e40942a0044224445fce0aaf862c5fa18d Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Sat, 11 Apr 2026 19:51:32 +0300 Subject: [PATCH 2/7] fix(wsl): address codex review findings - openInTerminal PowerShell paths went through /bin/sh via execSync + JSON.stringify, so $Host in the command was expanded to empty before PowerShell saw it. Switch both Windows-side and WSL-side PS branches to execFileSync('powershell.exe', [...argv]) so the shell never sees the payload. - Also single-quote-escape WSL_DISTRO_NAME instead of JSON.stringify so the same argv-only path works for the -d flag. - openIDE called execSync but server.js only imported exec / execFile; the ReferenceError was swallowed by a bare catch {}, permanently disabling the Linux wrapper detection. Import execFileSync and probe via ['which', bin]; narrow the catch so only exit-1 "not found" stays silent. - openIDE also built shell strings like `cursor "${target}"`, which broke on paths containing quotes. Route every branch through execFile(bin, [target]). --- src/server.js | 31 +++++++++++++++++-------------- src/terminals.js | 25 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/server.js b/src/server.js index 178242f..6851192 100644 --- a/src/server.js +++ b/src/server.js @@ -2,7 +2,7 @@ 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, isWSL } = require('./terminals'); const { convertSession } = require('./convert'); @@ -458,9 +458,12 @@ 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()) { - exec(`${bin} "${target}"`); + // execFile with argv — a project path containing quotes or spaces must not + // get re-parsed by /bin/sh. + execFile(bin, [target], runLog); return; } @@ -472,35 +475,35 @@ function openIDE(ide, target) { let winTarget = target; const m = target.match(/^\/mnt\/([a-z])\/(.*)$/i); if (m) winTarget = m[1].toUpperCase() + ':\\' + m[2].replace(/\//g, '\\'); - execFile(winBin, [winTarget], (err) => { - if (err) log('ERROR', `${winBin} failed: ${err.message}`); - }); + execFile(winBin, [winTarget], runLog); return; } // WSL-side project: prefer the Linux wrapper installed by the Remote-WSL - // extension since it handles path translation. Fall back to .exe with - // --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 { - execSync(`which ${bin}`, { stdio: 'pipe' }); + execFileSync('which', [bin], { stdio: 'pipe' }); hasWrapper = true; - } catch {} + } catch (e) { + if (e.code !== 1 && !/not found|No such/.test(e.message || '')) { + log('WARN', `which ${bin} probe error: ${e.message}`); + } + } if (hasWrapper) { - exec(`${bin} "${target}"`); + 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}`); - exec(`${winBin} "${target}"`); + execFile(winBin, [target], runLog); return; } - execFile(winBin, ['--remote', `wsl+${distro}`, target], (err) => { - if (err) log('ERROR', `${winBin} failed: ${err.message}`); - }); + execFile(winBin, ['--remote', `wsl+${distro}`, target], runLog); } function sendHeartbeat() { diff --git a/src/terminals.js b/src/terminals.js index a30b37c..effb14c 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -281,8 +281,17 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { const winProjectDir = toWindowsPath(projectDir); if (tid === 'wsl-powershell') { const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; Set-Location -LiteralPath '${(winProjectDir || '.').replace(/'/g, "''")}'; ${cmd}`; - const psCmd = `Start-Process powershell -ArgumentList '-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`; - execSync(`powershell.exe -NoProfile -Command ${JSON.stringify(psCmd)}`, { stdio: 'pipe' }); + // execFileSync with an argv array avoids /bin/sh seeing $Host / $p etc. + // Start-Process -ArgumentList is the idiomatic way to detach a new + // console window from the caller. + execFileSync('powershell.exe', [ + '-NoProfile', + '-Command', + 'Start-Process', + 'powershell', + '-ArgumentList', + `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, + ], { stdio: 'pipe' }); } else { const args = ['-w', '0', 'new-tab', '--title', tag, '--suppressApplicationTitle']; if (winProjectDir) { args.push('--startingDirectory', winProjectDir); } @@ -296,12 +305,18 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { // wt.exe / powershell.exe / wsl.exe / bash stays safe. const scriptPath = writeWslLaunchScript(sessionId, fullCmd); const distro = wslDistro(); - const distroArg = distro ? ` -d ${JSON.stringify(distro)}` : ''; + const distroArg = distro ? ` -d '${distro.replace(/'/g, "''")}'` : ''; if (tid === 'wsl-powershell') { const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash ${scriptPath.replace(/'/g, "''")}`; - const psCmd = `Start-Process powershell -ArgumentList '-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`; - execSync(`powershell.exe -NoProfile -Command ${JSON.stringify(psCmd)}`, { stdio: 'pipe' }); + execFileSync('powershell.exe', [ + '-NoProfile', + '-Command', + 'Start-Process', + 'powershell', + '-ArgumentList', + `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, + ], { stdio: 'pipe' }); } else { const args = [ '-w', '0', From f8bd466e39e9a9112db8053d4207e0c4bbbe03ee Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Sat, 11 Apr 2026 19:58:25 +0300 Subject: [PATCH 3/7] fix(wsl): reliable focus + sessionId hardening - Focus previously matched against Get-Process.MainWindowTitle, but Windows Terminal exposes only the currently active tab title as the host process MainWindowTitle. Any inactive codedash tab became invisible to the scan, and tabs stacked via `-w 0` made focus effectively random. Rework in two parts: 1. Launch uses `wt.exe -w new` so every session gets its own top-level wt window instead of stacking tabs. 2. Focus PowerShell script enumerates visible top-level windows via Win32 EnumWindows + GetWindowText and matches the pinned codedash- tag. ShowWindowAsync(SW_RESTORE) still handles minimized windows before SetForegroundWindow. - Harden sessionId handling. assertSafeSessionId + a strict [A-Za-z0-9._-]{1,128} regex runs on every openInTerminal and focusTerminalByPid WSL call, and /api/launch + /api/focus reject malformed ids with HTTP 400 before touching any child process. All five supported agents use ids that fit this shape. - writeWslLaunchScript now creates a private directory via fs.mkdtempSync and writes a fixed launch.sh inside, so a client cannot influence the temp path layout. Cleanup uses fs.rmSync with recursive:true to wipe the mkdtemp dir after the child has read the script. --- src/server.js | 6 +++ src/terminals.js | 107 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/src/server.js b/src/server.js index 6851192..2a8bfa0 100644 --- a/src/server.js +++ b/src/server.js @@ -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'); @@ -241,6 +244,9 @@ function startServer(host, port, openBrowser = true) { readBody(req, body => { try { const { pid, sessionId } = JSON.parse(body); + 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}`); diff --git a/src/terminals.js b/src/terminals.js index effb14c..f2a4db6 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -38,9 +38,21 @@ function wslDistro() { return process.env.WSL_DISTRO_NAME || ''; } -// Tag used as tab/window title so focusTerminalByPid can locate the window later. +// 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); + return 'codedash-' + String(sessionId).slice(0, 12); } // A session is "Windows-side" if its cwd looks like a Windows path @@ -66,11 +78,12 @@ function toWindowsPath(p) { } } -// Write the resume command to a temp .sh file to avoid nested quoting through -// wt.exe / cmd.exe / powershell.exe / wsl.exe / bash. -function writeWslLaunchScript(sessionId, fullCmd) { - const dir = os.tmpdir(); - const file = path.join(dir, `codedash-launch-${sessionId || Date.now()}.sh`); +// 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', '# Auto-generated by codedash — safe to delete', @@ -81,7 +94,7 @@ function writeWslLaunchScript(sessionId, fullCmd) { '', ].join('\n'); fs.writeFileSync(file, body, { mode: 0o755 }); - return file; + return { file, dir: baseDir }; } // ── Detect available terminals ────────────────────────────── @@ -269,21 +282,21 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { } } } 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. + // 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 avoids /bin/sh seeing $Host / $p etc. - // Start-Process -ArgumentList is the idiomatic way to detach a new - // console window from the caller. + // 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', @@ -293,7 +306,11 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, ], { stdio: 'pipe' }); } else { - const args = ['-w', '0', 'new-tab', '--title', tag, '--suppressApplicationTitle']; + // `-w new` creates a fresh Windows Terminal window per session so + // focusTerminalByPid can later EnumWindows by the pinned title. + // Reusing `-w 0` would stack tabs and break focus, because the OS + // window title only ever reflects the currently active tab. + const args = ['-w', 'new', 'new-tab', '--title', tag, '--suppressApplicationTitle']; if (winProjectDir) { args.push('--startingDirectory', winProjectDir); } args.push('cmd.exe', '/k', cmd); execFileSafe('wt.exe', args); @@ -303,7 +320,7 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { // WSL-side session: wrap cd + resume in a .sh so nested quoting through // wt.exe / powershell.exe / wsl.exe / bash stays safe. - const scriptPath = writeWslLaunchScript(sessionId, fullCmd); + const { file: scriptPath, dir: scriptDir } = writeWslLaunchScript(fullCmd); const distro = wslDistro(); const distroArg = distro ? ` -d '${distro.replace(/'/g, "''")}'` : ''; @@ -318,8 +335,10 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, ], { stdio: 'pipe' }); } else { + // `-w new` — see comment above; one window per session keeps focus + // reliable. const args = [ - '-w', '0', + '-w', 'new', 'new-tab', '--title', tag, '--suppressApplicationTitle', @@ -329,7 +348,11 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { args.push('--', 'bash', scriptPath); execFileSafe('wt.exe', args); } - setTimeout(() => { try { fs.unlinkSync(scriptPath); } catch {} }, 60000); + // 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': @@ -392,26 +415,54 @@ function focusTerminalByPid(pid, sessionId) { // On WSL we launched terminals with a window/tab title tag tied to the // session id. Enumerate Windows processes and SetForegroundWindow on the // first match whose MainWindowTitle contains the tag. - if (!sessionId) return { ok: false, error: 'WSL focus requires sessionId' }; + try { assertSafeSessionId(sessionId); } catch (e) { + return { ok: false, error: e.message }; + } const tag = sessionTag(sessionId); - // Write a .ps1 script to a temp file — Node's exec runs through /bin/sh, - // which would otherwise expand PowerShell variables like $p and $t. + // We enumerate ALL top-level windows via Win32 EnumWindows instead of + // Get-Process.MainWindowTitle, because a Windows Terminal host process + // exposes only the currently active tab's title as MainWindowTitle — so + // any inactive codedash tab would be invisible to a process scan. With + // `-w new` on launch each session lives in its own wt window, making + // EnumWindows matching reliable. const psBody = [ 'param([Parameter(Mandatory=$true)][string]$Tag)', '$sig = @"', 'using System;', 'using System.Runtime.InteropServices;', + 'using System.Text;', '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);', + ' public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);', + ' [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, IntPtr lParam);', + ' [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int n);', + ' [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);', + ' [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);', + ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);', + ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);', + ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);', + ' public static IntPtr FindByTitle(string needle) {', + ' IntPtr found = IntPtr.Zero;', + ' EnumWindows((hWnd, lParam) => {', + ' if (!IsWindowVisible(hWnd)) return true;', + ' int len = GetWindowTextLength(hWnd);', + ' if (len <= 0) return true;', + ' StringBuilder sb = new StringBuilder(len + 1);', + ' GetWindowText(hWnd, sb, sb.Capacity);', + ' if (sb.ToString().IndexOf(needle, StringComparison.Ordinal) >= 0) {', + ' found = hWnd;', + ' return false;', + ' }', + ' return true;', + ' }, IntPtr.Zero);', + ' return found;', + ' }', '}', '"@', 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue', - '$proc = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like "*$Tag*" } | Select-Object -First 1', - 'if ($proc) {', - ' if ([CodedashWin]::IsIconic($proc.MainWindowHandle)) { [CodedashWin]::ShowWindowAsync($proc.MainWindowHandle, 9) | Out-Null }', - ' [CodedashWin]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null', + '$hwnd = [CodedashWin]::FindByTitle($Tag)', + 'if ($hwnd -ne [IntPtr]::Zero) {', + ' if ([CodedashWin]::IsIconic($hwnd)) { [CodedashWin]::ShowWindowAsync($hwnd, 9) | Out-Null }', + ' [CodedashWin]::SetForegroundWindow($hwnd) | Out-Null', " Write-Output 'ok'", '} else {', " Write-Output 'notfound'", From 389fffcb54bcc0128560130429e2f5c3cdc6ecdf Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Sat, 11 Apr 2026 22:32:38 +0300 Subject: [PATCH 4/7] fix(wsl): focus non-WT sessions and validate pid input Rewrite focusWslByPid to cover both Windows Terminal tabs and conhost windows (wsl-powershell launch path). The previous implementation relied solely on WT UIA + OSC 2 marker injection into the target pts, which regressed focus for non-WT sessions and could race with conhost hosts that honor OSC title sequences. Detect WT tabs via WT_SESSION in /proc//environ (WSLENV forwards it from the Windows side). Only mutate the pts via OSC when the target is actually a WT tab; for conhost sessions, skip straight to a non- mutating fallback that matches top-level windows by MainWindowTitle against the sessionTag pinned at launch via $Host.UI.RawUI.WindowTitle. WindowsTerminal is excluded from the fallback because MainWindowTitle only reflects the active tab. Harden /api/focus against bad input: reject non-positive-integer pid with a 400 before it can flow into /proc//fd/. Re-check in focusWslByPid as defense in depth for internal callers. --- src/server.js | 3 + src/terminals.js | 415 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 344 insertions(+), 74 deletions(-) diff --git a/src/server.js b/src/server.js index 2a8bfa0..41c9a53 100644 --- a/src/server.js +++ b/src/server.js @@ -244,6 +244,9 @@ function startServer(host, port, openBrowser = true) { readBody(req, body => { try { 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'); } diff --git a/src/terminals.js b/src/terminals.js index f2a4db6..055a105 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -405,6 +405,346 @@ 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, sessionId) { @@ -412,80 +752,7 @@ function focusTerminalByPid(pid, sessionId) { termLog('FOCUS', `focusTerminalByPid: pid=${pid} sessionId=${sessionId || '(none)'} platform=${platform}`); if (platform === 'linux' && isWSL()) { - // On WSL we launched terminals with a window/tab title tag tied to the - // session id. Enumerate Windows processes and SetForegroundWindow on the - // first match whose MainWindowTitle contains the tag. - try { assertSafeSessionId(sessionId); } catch (e) { - return { ok: false, error: e.message }; - } - const tag = sessionTag(sessionId); - // We enumerate ALL top-level windows via Win32 EnumWindows instead of - // Get-Process.MainWindowTitle, because a Windows Terminal host process - // exposes only the currently active tab's title as MainWindowTitle — so - // any inactive codedash tab would be invisible to a process scan. With - // `-w new` on launch each session lives in its own wt window, making - // EnumWindows matching reliable. - const psBody = [ - 'param([Parameter(Mandatory=$true)][string]$Tag)', - '$sig = @"', - 'using System;', - 'using System.Runtime.InteropServices;', - 'using System.Text;', - 'public class CodedashWin {', - ' public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);', - ' [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, IntPtr lParam);', - ' [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int n);', - ' [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);', - ' [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);', - ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);', - ' [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);', - ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);', - ' public static IntPtr FindByTitle(string needle) {', - ' IntPtr found = IntPtr.Zero;', - ' EnumWindows((hWnd, lParam) => {', - ' if (!IsWindowVisible(hWnd)) return true;', - ' int len = GetWindowTextLength(hWnd);', - ' if (len <= 0) return true;', - ' StringBuilder sb = new StringBuilder(len + 1);', - ' GetWindowText(hWnd, sb, sb.Capacity);', - ' if (sb.ToString().IndexOf(needle, StringComparison.Ordinal) >= 0) {', - ' found = hWnd;', - ' return false;', - ' }', - ' return true;', - ' }, IntPtr.Zero);', - ' return found;', - ' }', - '}', - '"@', - 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue', - '$hwnd = [CodedashWin]::FindByTitle($Tag)', - 'if ($hwnd -ne [IntPtr]::Zero) {', - ' if ([CodedashWin]::IsIconic($hwnd)) { [CodedashWin]::ShowWindowAsync($hwnd, 9) | Out-Null }', - ' [CodedashWin]::SetForegroundWindow($hwnd) | Out-Null', - " Write-Output 'ok'", - '} else {', - " Write-Output 'notfound'", - '}', - '', - ].join('\r\n'); - const ps1Path = path.join(os.tmpdir(), `codedash-focus-${Date.now()}.ps1`); - try { - fs.writeFileSync(ps1Path, psBody); - const out = execFileSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps1Path, '-Tag', tag], - { encoding: 'utf8', timeout: 5000 } - ).trim(); - termLog('FOCUS', `wsl result: ${out}`); - if (out.endsWith('ok')) return { ok: true, terminal: 'Windows Terminal / PowerShell' }; - return { ok: false, error: 'Window not found — launch the session from UI first' }; - } catch (e) { - termLog('ERROR', `wsl focus failed: ${e.message}`); - return { ok: false, error: e.message }; - } finally { - try { fs.unlinkSync(ps1Path); } catch {} - } + return focusWslByPid(pid, sessionId); } if (platform === 'darwin') { From a3ec439afa845fa9d478ef6a45dcb87b2381b0e9 Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev <vbponomarev@gmail.com> Date: Sat, 11 Apr 2026 22:46:43 +0300 Subject: [PATCH 5/7] fix(wsl): open resume as tab in current wt window, not new window Switch wt.exe launch args from `-w new` to `-w 0` in both the Windows- side and WSL-side launch branches. The previous `-w new` was only needed because the old focus logic relied on MainWindowTitle, which only reflects the currently active tab. UIA-based focus now enumerates every TabItem and selects the target via SelectionItemPattern, so stacking sessions as tabs in one window is safe. wt falls back to creating a fresh window when none exists, so the "no window" edge case is handled by the terminal itself. --- src/terminals.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/terminals.js b/src/terminals.js index 055a105..2dc3ace 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -306,11 +306,11 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, ], { stdio: 'pipe' }); } else { - // `-w new` creates a fresh Windows Terminal window per session so - // focusTerminalByPid can later EnumWindows by the pinned title. - // Reusing `-w 0` would stack tabs and break focus, because the OS - // window title only ever reflects the currently active tab. - const args = ['-w', 'new', 'new-tab', '--title', tag, '--suppressApplicationTitle']; + // `-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); @@ -335,10 +335,10 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { `'-NoExit','-NoProfile','-Command','${psInner.replace(/'/g, "''")}'`, ], { stdio: 'pipe' }); } else { - // `-w new` — see comment above; one window per session keeps focus - // reliable. + // `-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', 'new', + '-w', '0', 'new-tab', '--title', tag, '--suppressApplicationTitle', From aeba6b5184477436b1c4d178fb12b3908437f883 Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev <vbponomarev@gmail.com> Date: Sun, 12 Apr 2026 15:57:36 +0300 Subject: [PATCH 6/7] fix(wsl): use login shell for launch scripts so PATH includes user bins The trampoline launch.sh was invoked via `bash scriptPath`, which skips ~/.profile and ~/.bash_profile. Tools installed in ~/.local/bin (like claude) were not on PATH, causing "command not found" on resume. Switch all three invocation points to `bash -l`: the shebang line and both wt.exe/powershell.exe launch paths now run the script as a login shell, which sources the user's profile and picks up PATH additions. --- src/terminals.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminals.js b/src/terminals.js index 2dc3ace..2e71fd6 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -85,7 +85,7 @@ function writeWslLaunchScript(fullCmd) { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codedash-launch-')); const file = path.join(baseDir, 'launch.sh'); const body = [ - '#!/bin/bash', + '#!/bin/bash -l', '# Auto-generated by codedash — safe to delete', fullCmd, 'echo', @@ -325,7 +325,7 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { const distroArg = distro ? ` -d '${distro.replace(/'/g, "''")}'` : ''; if (tid === 'wsl-powershell') { - const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash ${scriptPath.replace(/'/g, "''")}`; + const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash -l ${scriptPath.replace(/'/g, "''")}`; execFileSync('powershell.exe', [ '-NoProfile', '-Command', @@ -345,7 +345,7 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { 'wsl.exe', ]; if (distro) { args.push('-d', distro); } - args.push('--', 'bash', scriptPath); + args.push('--', 'bash', '-l', scriptPath); execFileSafe('wt.exe', args); } // bash reads the script on startup; 60 s is plenty. fs.rm with recursive From d892d1c6ca76968b41bd3a7beeffcaee0d2b2a82 Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev <vbponomarev@gmail.com> Date: Sun, 12 Apr 2026 16:48:15 +0300 Subject: [PATCH 7/7] fix(wsl): use interactive login shell so ~/.bashrc is sourced on resume bash -l (login-only) reads ~/.profile but skips ~/.bashrc, which is where many users configure PATH additions (nvm, pyenv, etc.) and shell customizations. Switch WSL launch invocations from bash -l to bash -li so the trampoline script runs as both login and interactive, sourcing the full user environment. --- src/terminals.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminals.js b/src/terminals.js index 2e71fd6..cfd3c87 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -325,7 +325,7 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { const distroArg = distro ? ` -d '${distro.replace(/'/g, "''")}'` : ''; if (tid === 'wsl-powershell') { - const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash -l ${scriptPath.replace(/'/g, "''")}`; + const psInner = `$Host.UI.RawUI.WindowTitle='${tag}'; wsl.exe${distroArg} -- bash -li ${scriptPath.replace(/'/g, "''")}`; execFileSync('powershell.exe', [ '-NoProfile', '-Command', @@ -345,7 +345,7 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) { 'wsl.exe', ]; if (distro) { args.push('-d', distro); } - args.push('--', 'bash', '-l', scriptPath); + args.push('--', 'bash', '-li', scriptPath); execFileSafe('wt.exe', args); } // bash reads the script on startup; 60 s is plenty. fs.rm with recursive