diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c24d6b43..94147c79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -327,7 +327,7 @@ jobs: core.setOutput('notes', notes); - name: Notify Discord - if: steps.check_artifacts.outputs.has_artifacts == 'true' + if: steps.check_artifacts.outputs.has_artifacts == 'true' && !endsWith(github.ref_name, '-RC') uses: sarisia/actions-status-discord@v1 with: webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/src/main/debug-package/collectors/windows-diagnostics.ts b/src/main/debug-package/collectors/windows-diagnostics.ts new file mode 100644 index 00000000..3b66f473 --- /dev/null +++ b/src/main/debug-package/collectors/windows-diagnostics.ts @@ -0,0 +1,341 @@ +/** + * Windows Diagnostics Collector + * + * Collects Windows-specific diagnostic information for troubleshooting + * agent detection and process spawning issues on Windows platforms. + * + * This collector is only active on Windows (process.platform === 'win32'). + */ + +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import { execFileNoThrow } from '../../utils/execFile'; +import { sanitizePath } from './settings'; + +export interface WindowsDiagnosticsInfo { + isWindows: boolean; + // Only populated on Windows + environment?: { + pathext: string[]; // PATHEXT extensions (what Windows considers executable) + pathDirs: string[]; // Sanitized PATH directories + pathDirsCount: number; + appData: string; // Sanitized APPDATA path + localAppData: string; // Sanitized LOCALAPPDATA path + programFiles: string; // Sanitized Program Files path + userProfile: string; // Sanitized user profile (home) path + }; + agentProbing?: { + // For each agent, show what paths were probed and what was found + claude: AgentProbeResult; + codex: AgentProbeResult; + opencode: AgentProbeResult; + gemini: AgentProbeResult; + aider: AgentProbeResult; + }; + whereResults?: { + // Results from 'where' command for each agent + claude: WhereResult; + codex: WhereResult; + opencode: WhereResult; + gemini: WhereResult; + aider: WhereResult; + }; + npmInfo?: { + npmGlobalPrefix: string | null; // npm config get prefix (sanitized) + npmVersion: string | null; + nodeVersion: string | null; + }; + fileSystemChecks?: { + // Check if common installation directories exist + npmGlobalDir: DirectoryCheck; + localBinDir: DirectoryCheck; + wingetLinksDir: DirectoryCheck; + scoopShimsDir: DirectoryCheck; + chocolateyBinDir: DirectoryCheck; + pythonScriptsDir: DirectoryCheck; + }; +} + +export interface AgentProbeResult { + probedPaths: Array<{ + path: string; // Sanitized path + exists: boolean; + isFile: boolean; + extension: string; + }>; + foundPath: string | null; // First path that was found (sanitized) +} + +export interface WhereResult { + success: boolean; + exitCode: number; + paths: string[]; // Sanitized paths returned by where + error?: string; +} + +export interface DirectoryCheck { + path: string; // Sanitized path + exists: boolean; + isDirectory: boolean; + files?: string[]; // List of executables found (if exists) +} + +/** + * Collect Windows-specific diagnostics. + * Returns minimal info on non-Windows platforms. + */ +export async function collectWindowsDiagnostics(): Promise { + const isWindows = process.platform === 'win32'; + + if (!isWindows) { + return { isWindows: false }; + } + + const result: WindowsDiagnosticsInfo = { + isWindows: true, + }; + + // Collect environment info + result.environment = collectEnvironmentInfo(); + + // Probe for agent binaries + result.agentProbing = { + claude: await probeAgentPaths('claude'), + codex: await probeAgentPaths('codex'), + opencode: await probeAgentPaths('opencode'), + gemini: await probeAgentPaths('gemini'), + aider: await probeAgentPaths('aider'), + }; + + // Run 'where' command for each agent + result.whereResults = { + claude: await runWhereCommand('claude'), + codex: await runWhereCommand('codex'), + opencode: await runWhereCommand('opencode'), + gemini: await runWhereCommand('gemini'), + aider: await runWhereCommand('aider'), + }; + + // Collect npm info + result.npmInfo = await collectNpmInfo(); + + // Check common installation directories + result.fileSystemChecks = await checkInstallationDirectories(); + + return result; +} + +function collectEnvironmentInfo(): WindowsDiagnosticsInfo['environment'] { + const home = os.homedir(); + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const pathext = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean); + const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + + return { + pathext, + pathDirs: pathDirs.map(p => sanitizePath(p)), + pathDirsCount: pathDirs.length, + appData: sanitizePath(appData), + localAppData: sanitizePath(localAppData), + programFiles: sanitizePath(programFiles), + userProfile: sanitizePath(home), + }; +} + +async function probeAgentPaths(binaryName: string): Promise { + const home = os.homedir(); + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const chocolateyInstall = process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey'; + + // Known installation paths for each agent + const knownPaths: Record = { + claude: [ + // PowerShell installer location + path.join(home, '.local', 'bin', 'claude.exe'), + // winget locations + path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'claude.exe'), + path.join(programFiles, 'WinGet', 'Links', 'claude.exe'), + // npm global locations + path.join(appData, 'npm', 'claude.cmd'), + path.join(localAppData, 'npm', 'claude.cmd'), + // Windows Apps (Microsoft Store / App Installer) + path.join(localAppData, 'Microsoft', 'WindowsApps', 'claude.exe'), + ], + codex: [ + path.join(appData, 'npm', 'codex.cmd'), + path.join(localAppData, 'npm', 'codex.cmd'), + path.join(home, '.local', 'bin', 'codex.exe'), + ], + opencode: [ + path.join(home, 'scoop', 'shims', 'opencode.exe'), + path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'), + path.join(chocolateyInstall, 'bin', 'opencode.exe'), + path.join(home, 'go', 'bin', 'opencode.exe'), + path.join(appData, 'npm', 'opencode.cmd'), + ], + gemini: [ + path.join(appData, 'npm', 'gemini.cmd'), + path.join(localAppData, 'npm', 'gemini.cmd'), + ], + aider: [ + path.join(appData, 'Python', 'Scripts', 'aider.exe'), + path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts', 'aider.exe'), + path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts', 'aider.exe'), + path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts', 'aider.exe'), + ], + }; + + const pathsToProbe = knownPaths[binaryName] || []; + const probedPaths: AgentProbeResult['probedPaths'] = []; + let foundPath: string | null = null; + + for (const probePath of pathsToProbe) { + let exists = false; + let isFile = false; + + try { + const stats = fs.statSync(probePath); + exists = true; + isFile = stats.isFile(); + if (isFile && !foundPath) { + foundPath = probePath; + } + } catch { + // Path doesn't exist + } + + probedPaths.push({ + path: sanitizePath(probePath), + exists, + isFile, + extension: path.extname(probePath).toLowerCase(), + }); + } + + return { + probedPaths, + foundPath: foundPath ? sanitizePath(foundPath) : null, + }; +} + +async function runWhereCommand(binaryName: string): Promise { + try { + const result = await execFileNoThrow('where', [binaryName]); + const paths = result.stdout + .split(/\r?\n/) + .map(p => p.trim()) + .filter(Boolean) + .map(p => sanitizePath(p)); + + return { + success: result.exitCode === 0, + exitCode: result.exitCode, + paths, + error: result.exitCode !== 0 ? result.stderr : undefined, + }; + } catch (error) { + return { + success: false, + exitCode: -1, + paths: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function collectNpmInfo(): Promise { + let npmGlobalPrefix: string | null = null; + let npmVersion: string | null = null; + let nodeVersion: string | null = null; + + try { + const prefixResult = await execFileNoThrow('npm', ['config', 'get', 'prefix']); + if (prefixResult.exitCode === 0) { + npmGlobalPrefix = sanitizePath(prefixResult.stdout.trim()); + } + } catch { + // npm not available + } + + try { + const versionResult = await execFileNoThrow('npm', ['--version']); + if (versionResult.exitCode === 0) { + npmVersion = versionResult.stdout.trim(); + } + } catch { + // npm not available + } + + try { + const nodeResult = await execFileNoThrow('node', ['--version']); + if (nodeResult.exitCode === 0) { + nodeVersion = nodeResult.stdout.trim(); + } + } catch { + // node not available + } + + return { + npmGlobalPrefix, + npmVersion, + nodeVersion, + }; +} + +async function checkInstallationDirectories(): Promise { + const home = os.homedir(); + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const chocolateyInstall = process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey'; + + const dirsToCheck: Record = { + npmGlobalDir: path.join(appData, 'npm'), + localBinDir: path.join(home, '.local', 'bin'), + wingetLinksDir: path.join(localAppData, 'Microsoft', 'WinGet', 'Links'), + scoopShimsDir: path.join(home, 'scoop', 'shims'), + chocolateyBinDir: path.join(chocolateyInstall, 'bin'), + pythonScriptsDir: path.join(appData, 'Python', 'Scripts'), + }; + + const result: Record = {}; + + for (const [key, dirPath] of Object.entries(dirsToCheck)) { + const check: DirectoryCheck = { + path: sanitizePath(dirPath), + exists: false, + isDirectory: false, + }; + + try { + const stats = fs.statSync(dirPath); + check.exists = true; + check.isDirectory = stats.isDirectory(); + + if (check.isDirectory) { + // List executable files in the directory + try { + const files = fs.readdirSync(dirPath); + const executables = files.filter(f => { + const ext = path.extname(f).toLowerCase(); + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + }); + // Only include first 20 executables to avoid huge output + check.files = executables.slice(0, 20); + } catch { + // Can't read directory contents + } + } + } catch { + // Directory doesn't exist + } + + result[key] = check; + } + + return result as WindowsDiagnosticsInfo['fileSystemChecks']; +} diff --git a/src/main/debug-package/index.ts b/src/main/debug-package/index.ts index bdfb2b5d..9a875144 100644 --- a/src/main/debug-package/index.ts +++ b/src/main/debug-package/index.ts @@ -23,6 +23,7 @@ import { collectWebServer, WebServerInfo } from './collectors/web-server'; import { collectStorage, StorageInfo } from './collectors/storage'; import { collectGroupChats, GroupChatInfo } from './collectors/group-chats'; import { collectBatchState, BatchStateInfo } from './collectors/batch-state'; +import { collectWindowsDiagnostics, WindowsDiagnosticsInfo } from './collectors/windows-diagnostics'; import { createZipPackage, PackageContents } from './packager'; import { logger } from '../utils/logger'; import { AgentDetector } from '../agent-detector'; @@ -125,6 +126,17 @@ export async function generateDebugPackage( logger.error('Failed to collect external tools info', 'DebugPackage', error); } + // Collect Windows-specific diagnostics (always included, minimal on non-Windows) + try { + const windowsDiagnostics = await collectWindowsDiagnostics(); + contents['windows-diagnostics.json'] = windowsDiagnostics; + filesIncluded.push('windows-diagnostics.json'); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + errors.push(`windows-diagnostics: ${errMsg}`); + logger.error('Failed to collect Windows diagnostics', 'DebugPackage', error); + } + // Collect groups (always included) try { const groupsData = deps.groupsStore.get('groups', []); @@ -287,6 +299,7 @@ export function previewDebugPackage(): { { id: 'settings', name: 'Settings', included: true, sizeEstimate: '< 5 KB' }, { id: 'agents', name: 'Agent Configurations', included: true, sizeEstimate: '< 2 KB' }, { id: 'externalTools', name: 'External Tools', included: true, sizeEstimate: '< 2 KB' }, + { id: 'windowsDiagnostics', name: 'Windows Diagnostics', included: true, sizeEstimate: '< 10 KB' }, { id: 'sessions', name: 'Session Metadata', included: true, sizeEstimate: '~10-50 KB' }, { id: 'logs', name: 'System Logs', included: true, sizeEstimate: '~50-200 KB' }, { id: 'errors', name: 'Error States', included: true, sizeEstimate: '< 10 KB' }, @@ -304,6 +317,7 @@ export type { SanitizedSettings, AgentsInfo, ExternalToolsInfo, + WindowsDiagnosticsInfo, SessionInfo, ProcessInfo, LogsInfo, diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index 64eff8c6..f61fba3a 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -195,20 +195,32 @@ export function QuickActionsModal(props: QuickActionsModalProps) { setQuickActionOpen(false); }; - const sessionActions: QuickAction[] = sessions.map(s => ({ - id: `jump-${s.id}`, - label: `Jump to: ${s.name}`, - action: () => { - setActiveSessionId(s.id); - // Auto-expand group if it's collapsed - if (s.groupId) { - setGroups(prev => prev.map(g => - g.id === s.groupId && g.collapsed ? { ...g, collapsed: false } : g - )); - } - }, - subtext: s.state.toUpperCase() - })); + const sessionActions: QuickAction[] = sessions.map(s => { + // For worktree subagents, format as "Jump to $PARENT subagent: $NAME" + let label: string; + if (s.parentSessionId) { + const parentSession = sessions.find(p => p.id === s.parentSessionId); + const parentName = parentSession?.name || 'Unknown'; + label = `Jump to ${parentName} subagent: ${s.name}`; + } else { + label = `Jump to: ${s.name}`; + } + + return { + id: `jump-${s.id}`, + label, + action: () => { + setActiveSessionId(s.id); + // Auto-expand group if it's collapsed + if (s.groupId) { + setGroups(prev => prev.map(g => + g.id === s.groupId && g.collapsed ? { ...g, collapsed: false } : g + )); + } + }, + subtext: s.state.toUpperCase() + }; + }); // Group chat jump actions const groupChatActions: QuickAction[] = (groupChats && onOpenGroupChat)