diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index a4eb21d7..fb7fd848 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow'; import { buildSyntheticRepositoryGroup, + encodeProjectPathForNavigation, findMatchingWorktree, type WorktreeMatch, } from '../utils/navigation'; @@ -79,7 +80,7 @@ export function useOpenRecentProject(): { repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups], })); - const encodedId = path.replace(/[/\\]/g, '-'); + const encodedId = encodeProjectPathForNavigation(path); navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); }, [fetchRepositoryGroups, navigateToMatch, repositoryGroups] diff --git a/src/features/recent-projects/renderer/utils/navigation.ts b/src/features/recent-projects/renderer/utils/navigation.ts index 3dff676a..747eea58 100644 --- a/src/features/recent-projects/renderer/utils/navigation.ts +++ b/src/features/recent-projects/renderer/utils/navigation.ts @@ -24,8 +24,22 @@ export function findMatchingWorktree( return null; } +export function encodeProjectPathForNavigation(projectPath: string): string { + if (!projectPath) { + return ''; + } + + const encoded = projectPath.replace(/[/\\]/g, '-'); + const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded); + if (windowsDriveMatch) { + return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`; + } + + return encoded.startsWith('-') ? encoded : `-${encoded}`; +} + export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup { - const encodedId = selectedPath.replace(/[/\\]/g, '-'); + const encodedId = encodeProjectPathForNavigation(selectedPath); const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; const now = Date.now(); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts index 2f9f3f23..737eaff4 100644 --- a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts @@ -21,7 +21,7 @@ describe('buildTmuxEffectiveAvailability', () => { expect(result.runtimeReady).toBe(true); }); - it('prefers WSL tmux on Windows when it is available', () => { + it('keeps WSL tmux visible but non-runtime-ready on Windows', () => { const result = buildTmuxEffectiveAvailability({ platform: 'win32', nativeSupported: false, @@ -47,7 +47,7 @@ describe('buildTmuxEffectiveAvailability', () => { expect(result.available).toBe(true); expect(result.location).toBe('wsl'); - expect(result.runtimeReady).toBe(true); + expect(result.runtimeReady).toBe(false); expect(result.version).toBe('tmux 3.4'); }); diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts index cb4a0bf0..b6fe3d0c 100644 --- a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -22,10 +22,9 @@ export function buildTmuxEffectiveAvailability( location: 'wsl', version: input.wsl.tmuxVersion, binaryPath: input.wsl.tmuxBinaryPath, - runtimeReady: input.wsl.distroBootstrapped, - detail: input.wsl.distroBootstrapped - ? 'tmux is available inside WSL for the persistent teammate runtime.' - : 'tmux is installed inside WSL, but the Linux distro still needs first-launch setup.', + runtimeReady: false, + detail: + 'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.', }; } diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index 84cc73a1..b6ccfcda 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -420,9 +420,15 @@ export class TmuxWslService { .split(/\r?\n/) .map((line) => line.replace(/\0/g, '').trim()) .map((line) => line.replace(/^\*\s*/, '').trim()) + .filter((line) => !this.#isInternalWslDistro(line)) .filter(Boolean); } + #isInternalWslDistro(name: string): boolean { + const normalized = name.trim().toLowerCase(); + return normalized === 'docker-desktop' || normalized === 'docker-desktop-data'; + } + #parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] { const sortedDistros = [...distros].sort((left, right) => right.length - left.length); const entries: WslVerboseDistroEntry[] = []; diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts index e1ed78f5..c65aa4a7 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -149,6 +149,22 @@ describe('TmuxWslService', () => { expect(preferenceStore.getPreferredDistroSync()).toBeNull(); }); + it('ignores Docker internal WSL distros when choosing a teammate runtime distro', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'docker-desktop\ndocker-desktop-data\n' }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(true); + expect(result.status.distroName).toBeNull(); + expect(result.status.statusDetail).toContain('no Linux distribution'); + }); + it('switches preference source away from persisted after clearing a stale distro', async () => { const preferenceStore = createPreferenceStore('Ubuntu'); const service = new TmuxWslService( diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index 2b7f7480..93b6c192 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -14,19 +14,19 @@ import { type SemanticStepGroup, type SubagentDetail, } from '@main/types'; -import { extractBaseDir } from '@main/utils/pathDecoder'; import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; -const logger = createLogger('Service:SubagentDetailBuilder'); - import { buildSemanticStepGroups } from './SemanticStepGrouper'; +import { resolveProjectStorageDir } from '../discovery/projectStorageDir'; import type { SubagentResolver } from '../discovery/SubagentResolver'; import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; import type { SessionParser } from '../parsing/SessionParser'; +const logger = createLogger('Service:SubagentDetailBuilder'); + /** * Build detailed information for a specific subagent. * Used for drill-down modal to show subagent's internal execution. @@ -52,12 +52,14 @@ export async function buildSubagentDetail( projectsDir: string ): Promise { try { - // Construct path to subagent JSONL file - // projectId may be composite (e.g. "baseDir::suffix"), extract base dir - const baseDir = extractBaseDir(projectId); + const projectPath = await resolveProjectStorageDir(projectsDir, projectId, fsProvider); + if (!projectPath) { + logger.warn(`Project storage directory not found for subagent detail: ${projectId}`); + return null; + } + const subagentPath = path.join( - projectsDir, - baseDir, + projectPath, sessionId, 'subagents', `agent-${subagentId}.jsonl` diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 38337f23..e95fb9e1 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -47,7 +47,6 @@ import { extractBaseDir, extractProjectName, extractSessionId, - getProjectDirNameCandidates, getProjectsBasePath, getTodosBasePath, isValidEncodedPath, @@ -59,6 +58,7 @@ import { configManager } from '../infrastructure/ConfigManager'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; +import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir'; import { SessionContentFilter } from './SessionContentFilter'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; @@ -633,13 +633,12 @@ export class ProjectScanner { */ async listSessions(projectId: string): Promise { try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return []; } @@ -722,14 +721,13 @@ export class ProjectScanner { try { const includeTotalCount = options?.includeTotalCount ?? false; const prefilterAll = options?.prefilterAll ?? false; - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; } @@ -1140,9 +1138,9 @@ export class ProjectScanner { * Gets a single session's metadata. */ async getSession(projectId: string, sessionId: string): Promise { - const filePath = this.getSessionPath(projectId, sessionId); + const filePath = await this.resolveSessionPath(projectId, sessionId); - if (!(await this.fsProvider.exists(filePath))) { + if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } @@ -1159,9 +1157,9 @@ export class ProjectScanner { sessionId: string, options?: SessionsByIdsOptions ): Promise { - const filePath = this.getSessionPath(projectId, sessionId); + const filePath = await this.resolveSessionPath(projectId, sessionId); - if (!(await this.fsProvider.exists(filePath))) { + if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } @@ -1207,6 +1205,14 @@ export class ProjectScanner { return buildSessionPath(this.projectsDir, projectId, sessionId); } + /** + * Resolves a session path using all known project storage directory codecs. + */ + async resolveSessionPath(projectId: string, sessionId: string): Promise { + const projectPath = await this.resolveProjectStorageDir(projectId); + return projectPath ? path.join(projectPath, `${sessionId}.jsonl`) : null; + } + /** * Gets the path to the subagents directory. */ @@ -1242,13 +1248,7 @@ export class ProjectScanner { } private async resolveProjectStorageDir(projectId: string): Promise { - for (const dirName of getProjectDirNameCandidates(projectId)) { - const projectPath = path.join(this.projectsDir, dirName); - if (await this.fsProvider.exists(projectPath)) { - return projectPath; - } - } - return null; + return resolveProjectStorageDirFromCandidates(this.projectsDir, projectId, this.fsProvider); } /** diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 0d1b1857..4bb72868 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -13,7 +13,7 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { parseJsonlFile } from '@main/utils/jsonl'; -import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; +import { extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; @@ -21,6 +21,7 @@ import { startMainSpan } from '../../sentry'; import { SearchTextCache } from './SearchTextCache'; import { extractSearchableEntries } from './SearchTextExtractor'; +import { resolveProjectStorageDir } from './projectStorageDir'; import { subprojectRegistry } from './SubprojectRegistry'; import type { SearchableEntry } from './SearchTextExtractor'; @@ -74,11 +75,14 @@ export class SessionSearcher { const normalizedQuery = query.toLowerCase().trim(); try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await resolveProjectStorageDir( + this.projectsDir, + projectId, + this.fsProvider + ); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } @@ -283,9 +287,7 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + - context + - (contextEnd < entry.text.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + context + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts index 961d2620..fdb32c00 100644 --- a/src/main/services/discovery/SubagentLocator.ts +++ b/src/main/services/discovery/SubagentLocator.ts @@ -9,11 +9,13 @@ */ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; -import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder'; +import { buildSubagentsPath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import { resolveProjectStorageDir, resolveProjectStorageDirSync } from './projectStorageDir'; + import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:SubagentLocator'); @@ -40,7 +42,10 @@ export class SubagentLocator { */ async hasSubagents(projectId: string, sessionId: string): Promise { // Check NEW structure: {projectId}/{sessionId}/subagents/ - const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId); + if (!newSubagentsPath) { + return false; + } try { const entries = await this.fsProvider.readdir(newSubagentsPath); // A non-empty agent-*.jsonl file is sufficient proof of subagents. @@ -93,7 +98,10 @@ export class SubagentLocator { */ async listSubagentFiles(projectId: string, sessionId: string): Promise { try { - const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + const newSubagentsPath = await this.resolveSubagentsPath(projectId, sessionId); + if (!newSubagentsPath) { + return []; + } if (await this.fsProvider.exists(newSubagentsPath)) { const entries = await this.fsProvider.readdir(newSubagentsPath); return entries @@ -118,6 +126,18 @@ export class SubagentLocator { * @returns Path to the subagents directory */ getSubagentsPath(projectId: string, sessionId: string): string { - return buildSubagentsPath(this.projectsDir, projectId, sessionId); + const projectPath = resolveProjectStorageDirSync(this.projectsDir, projectId); + return projectPath + ? path.join(projectPath, sessionId, 'subagents') + : buildSubagentsPath(this.projectsDir, projectId, sessionId); + } + + private async resolveSubagentsPath(projectId: string, sessionId: string): Promise { + const projectPath = await resolveProjectStorageDir( + this.projectsDir, + projectId, + this.fsProvider + ); + return projectPath ? path.join(projectPath, sessionId, 'subagents') : null; } } diff --git a/src/main/services/discovery/projectStorageDir.ts b/src/main/services/discovery/projectStorageDir.ts new file mode 100644 index 00000000..21faf21f --- /dev/null +++ b/src/main/services/discovery/projectStorageDir.ts @@ -0,0 +1,32 @@ +import { getProjectDirNameCandidates } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; + +export async function resolveProjectStorageDir( + projectsDir: string, + projectId: string, + fsProvider: FileSystemProvider +): Promise { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(projectsDir, dirName); + if (await fsProvider.exists(projectPath)) { + return projectPath; + } + } + return null; +} + +export function resolveProjectStorageDirSync( + projectsDir: string, + projectId: string +): string | null { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(projectsDir, dirName); + if (fs.existsSync(projectPath)) { + return projectPath; + } + } + return null; +} diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index e203770c..e846bfb0 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -547,7 +547,7 @@ export class ProjectFileService { const newPath = path.join(normalizedDest, path.basename(normalizedSrc)); // 8. Prevent parent → child move (moving dir into itself) - if (normalizedDest.startsWith(normalizedSrc + path.sep) || normalizedDest === normalizedSrc) { + if (isPathWithinRoot(normalizedDest, normalizedSrc)) { throw new Error('Cannot move a directory into itself'); } diff --git a/src/main/services/extensions/skills/SkillsCatalogService.ts b/src/main/services/extensions/skills/SkillsCatalogService.ts index f207ffd8..be82d7ca 100644 --- a/src/main/services/extensions/skills/SkillsCatalogService.ts +++ b/src/main/services/extensions/skills/SkillsCatalogService.ts @@ -77,11 +77,16 @@ export class SkillsCatalogService { } private isWithinRoot(targetPath: string, rootPath: string): boolean { - const normalizedTarget = path.resolve(targetPath); - const normalizedRoot = path.resolve(rootPath); + const normalizedTarget = this.normalizeForContainment(targetPath); + const normalizedRoot = this.normalizeForContainment(rootPath); + const relativePath = path.relative(normalizedRoot, normalizedTarget); return ( - normalizedTarget === normalizedRoot || - normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) ); } + + private normalizeForContainment(value: string): string { + const resolved = path.resolve(path.normalize(value)); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } } diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index fc5c85bd..3219f4b6 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -67,7 +67,9 @@ export class SessionParser { * Parse a session JSONL file and return structured data. */ async parseSession(projectId: string, sessionId: string): Promise { - const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId); + const sessionPath = + (await this.projectScanner.resolveSessionPath(projectId, sessionId)) ?? + this.projectScanner.getSessionPath(projectId, sessionId); return this.parseSessionFile(sessionPath); } diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 43e9b006..9827ff3b 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -147,25 +147,19 @@ async function resolveFromExplicitPath(inputPath: string): Promise => { + if (windowsHostProcessRows) { + return windowsHostProcessRows; + } + try { + windowsHostProcessRows = await listWindowsProcessTable(); + windowsHostProcessTableAvailable = true; + } catch (error) { + windowsHostProcessRows = []; + logger.debug( + `[${teamName}] Failed to read Windows host process table for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return windowsHostProcessRows; + }; for (const [memberName, metadata] of metadataByMember.entries()) { const paneId = metadata.tmuxPaneId?.trim() ?? ''; @@ -15106,6 +15130,20 @@ export class TeamProvisioningService { } : undefined; const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; + const shouldUseWindowsHostRows = + process.platform === 'win32' && + (metadata.providerId === 'opencode' || + launchMember?.providerId === 'opencode' || + metadata.backendType !== 'tmux') && + currentRuntimeAdapterRun?.members?.[memberName]?.runtimeAlive !== true && + currentRuntimeAdapterRun?.members?.[memberName]?.bootstrapConfirmed !== true; + const hostProcessRows = shouldUseWindowsHostRows ? await getWindowsHostProcessRows() : []; + const memberProcessRows = shouldUseWindowsHostRows + ? [...hostProcessRows, ...processRows] + : processRows; + const memberProcessTableAvailable = shouldUseWindowsHostRows + ? windowsHostProcessTableAvailable || processTableAvailable + : processTableAvailable; const resolved = resolveTeamMemberRuntimeLiveness({ teamName, memberName, @@ -15119,8 +15157,8 @@ export class TeamProvisioningService { runtimePid: metadata.metricsPid, runtimeSessionId: metadata.runtimeSessionId, pane: paneId ? paneInfoById.get(paneId) : undefined, - processRows, - processTableAvailable, + processRows: memberProcessRows, + processTableAvailable: memberProcessTableAvailable, nowIso: nowIso(), }); metadataByMember.set(memberName, { @@ -17638,37 +17676,50 @@ export class TeamProvisioningService { } private killOrphanedTeamAgentProcesses(teamName: string): void { - if (process.platform === 'win32') { - return; - } - - let output = ''; - try { - output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - } catch { - return; - } - const currentRunPid = this.getTrackedRunId(teamName) ? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid : undefined; - const marker = `--team-name ${teamName}`; const pids = new Set(); + const rows: Array<{ pid: number; command: string }> = []; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.includes(marker) || !trimmed.includes('--agent-id')) { + if (process.platform === 'win32') { + try { + rows.push( + ...listWindowsProcessTableSync().map((row) => ({ pid: row.pid, command: row.command })) + ); + } catch { + return; + } + } else { + let output = ''; + try { + output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return; + } + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + const match = /^(\d+)\s+(.*)$/.exec(trimmed); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + if (!Number.isFinite(pid) || pid <= 0) continue; + rows.push({ pid, command: match[2] ?? '' }); + } + } + + for (const row of rows) { + if ( + !commandArgEquals(row.command, '--team-name', teamName) || + !row.command.includes('--agent-id') + ) { continue; } - const match = /^(\d+)\s+(.*)$/.exec(trimmed); - if (!match) continue; - const pid = Number.parseInt(match[1], 10); - if (!Number.isFinite(pid) || pid <= 0) continue; - if (currentRunPid && pid === currentRunPid) continue; - pids.add(pid); + if (currentRunPid && row.pid === currentRunPid) continue; + pids.add(row.pid); } for (const pid of pids) { diff --git a/src/main/utils/windowsProcessTable.ts b/src/main/utils/windowsProcessTable.ts new file mode 100644 index 00000000..70e94e8f --- /dev/null +++ b/src/main/utils/windowsProcessTable.ts @@ -0,0 +1,105 @@ +import { execFile, execFileSync } from 'child_process'; + +export interface WindowsProcessTableRow { + pid: number; + ppid: number; + command: string; +} + +interface RawWindowsProcessRow { + ProcessId?: number | string | null; + ParentProcessId?: number | string | null; + CommandLine?: string | null; +} + +const PROCESS_TABLE_SCRIPT = [ + '$ErrorActionPreference = "Stop"', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress', +].join('; '); + +const PROCESS_TABLE_ARGS = [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + PROCESS_TABLE_SCRIPT, +]; + +function parsePositiveInteger(value: unknown): number | null { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' + ? Number.parseInt(value, 10) + : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null; +} + +export function parseWindowsProcessTableJson(stdout: string): WindowsProcessTableRow[] { + const trimmed = stdout.trim(); + if (!trimmed) { + return []; + } + + let parsed: RawWindowsProcessRow | RawWindowsProcessRow[]; + try { + parsed = JSON.parse(trimmed) as RawWindowsProcessRow | RawWindowsProcessRow[]; + } catch { + return []; + } + + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const result: WindowsProcessTableRow[] = []; + + for (const row of rows) { + const pid = parsePositiveInteger(row?.ProcessId); + const ppid = parsePositiveInteger(row?.ParentProcessId) ?? 0; + const command = row?.CommandLine?.trim() ?? ''; + if (!pid || !command) { + continue; + } + result.push({ pid, ppid, command }); + } + + return result; +} + +export async function listWindowsProcessTable( + timeoutMs = 4_000 +): Promise { + return new Promise((resolve, reject) => { + execFile( + 'powershell.exe', + PROCESS_TABLE_ARGS, + { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + if (stderr?.trim()) { + reject(new Error(stderr.trim())); + return; + } + resolve(parseWindowsProcessTableJson(String(stdout))); + } + ); + }); +} + +export function listWindowsProcessTableSync(timeoutMs = 4_000): WindowsProcessTableRow[] { + const stdout = execFileSync('powershell.exe', PROCESS_TABLE_ARGS, { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return parseWindowsProcessTableJson(String(stdout)); +} diff --git a/test/features/recent-projects/renderer/utils/navigation.test.ts b/test/features/recent-projects/renderer/utils/navigation.test.ts index 61da4f9b..3ed056b6 100644 --- a/test/features/recent-projects/renderer/utils/navigation.test.ts +++ b/test/features/recent-projects/renderer/utils/navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildSyntheticRepositoryGroup, + encodeProjectPathForNavigation, findMatchingWorktree, } from '@features/recent-projects/renderer/utils/navigation'; @@ -31,9 +32,7 @@ describe('recent-projects navigation utils', () => { }, ]; - expect( - findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other']) - ).toEqual({ + expect(findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other'])).toEqual({ repoId: 'repo-alpha', worktreeId: 'wt-alpha', }); @@ -59,4 +58,10 @@ describe('recent-projects navigation utils', () => { vi.useRealTimers(); }); + + it('encodes Windows custom project paths with the same drive format as session ids', () => { + expect(encodeProjectPathForNavigation('C:\\Users\\User\\PROJECT_IT\\сlaude_team')).toBe( + 'C--Users-User-PROJECT_IT-сlaude_team' + ); + }); }); diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts index 62c216c1..a26ddc14 100644 --- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -4,7 +4,9 @@ import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; +import { SessionSearcher } from '../../../../src/main/services/discovery/SessionSearcher'; import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; +import { SessionParser } from '../../../../src/main/services/parsing/SessionParser'; import { encodePathPortable } from '../../../../src/main/utils/pathDecoder'; function createSessionLine(opts: { cwd?: string; type?: string }): string { @@ -51,10 +53,7 @@ describe('ProjectScanner cwd split logic', () => { // Session WITHOUT cwd (older format) fs.writeFileSync( path.join(projectDir, 'session-no-cwd.jsonl'), - createSessionLine({ type: 'system' }) + - '\n' + - createSessionLine({ type: 'user' }) + - '\n' + createSessionLine({ type: 'system' }) + '\n' + createSessionLine({ type: 'user' }) + '\n' ); const scanner = new ProjectScanner(projectsDir); @@ -119,6 +118,19 @@ describe('ProjectScanner cwd split logic', () => { const scanner = new ProjectScanner(projectsDir); await expect(scanner.listSessionFiles(uiEncodedName)).resolves.toEqual([sessionPath]); + await expect(scanner.listSessions(uiEncodedName)).resolves.toHaveLength(1); + await expect(scanner.getSession(uiEncodedName, 'session-orchestrator')).resolves.toMatchObject({ + id: 'session-orchestrator', + projectId: uiEncodedName, + }); + + const parser = new SessionParser(scanner); + const parsed = await parser.parseSession(uiEncodedName, 'session-orchestrator'); + expect(parsed.messages).toHaveLength(1); + + const searcher = new SessionSearcher(projectsDir); + const searchResult = await searcher.searchSessions(uiEncodedName, 'hello', 10); + expect(searchResult.totalMatches).toBe(1); }); it('detects Windows forward-slash worktree paths', async () => { diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index 33677735..2378db8e 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -53,6 +53,7 @@ describe('ClaudeBinaryResolver', () => { const originalPlatform = process.platform; const originalCwd = process.cwd; const originalResourcesPath = process.resourcesPath; + const originalPathext = process.env.PATHEXT; const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime'; beforeEach(() => { @@ -91,6 +92,11 @@ describe('ClaudeBinaryResolver', () => { configurable: true, writable: true, }); + if (originalPathext === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathext; + } vi.unstubAllEnvs(); }); @@ -130,6 +136,31 @@ describe('ClaudeBinaryResolver', () => { expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); }); + it('resolves extensionless Windows explicit overrides to a real executable file first', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + writable: true, + }); + mockGetConfiguredCliFlavor.mockReturnValue('claude'); + process.env.PATHEXT = '.EXE;.CMD'; + process.env.CLAUDE_CLI_PATH = 'C:\\Tools\\claude'; + const expectedBinary = 'C:\\Tools\\claude.exe'; + + statMock.mockImplementation((filePath) => { + if (filePath === expectedBinary) { + return Promise.resolve({ isFile: () => true }); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver'); + ClaudeBinaryResolver.clearCache(); + + await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); + expect(statMock.mock.calls[0]?.[0]).toBe(expectedBinary); + }); + it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev'; diff --git a/test/main/utils/windowsProcessTable.test.ts b/test/main/utils/windowsProcessTable.test.ts new file mode 100644 index 00000000..c45f22e4 --- /dev/null +++ b/test/main/utils/windowsProcessTable.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWindowsProcessTableJson } from '../../../src/main/utils/windowsProcessTable'; + +describe('windowsProcessTable', () => { + it('parses PowerShell process table JSON objects and arrays', () => { + expect( + parseWindowsProcessTableJson( + JSON.stringify([ + { + ProcessId: 101, + ParentProcessId: 1, + CommandLine: 'node runtime --team-name demo --agent-id agent-a', + }, + { + ProcessId: '102', + ParentProcessId: '101', + CommandLine: 'opencode serve', + }, + { + ProcessId: 103, + ParentProcessId: 1, + CommandLine: null, + }, + ]) + ) + ).toEqual([ + { pid: 101, ppid: 1, command: 'node runtime --team-name demo --agent-id agent-a' }, + { pid: 102, ppid: 101, command: 'opencode serve' }, + ]); + + expect( + parseWindowsProcessTableJson( + JSON.stringify({ + ProcessId: 201, + ParentProcessId: 1, + CommandLine: 'claude --team-name demo --agent-id agent-b', + }) + ) + ).toEqual([{ pid: 201, ppid: 1, command: 'claude --team-name demo --agent-id agent-b' }]); + }); +});