fix(windows): harden runtime project compatibility
This commit is contained in:
parent
645ac4573e
commit
3f1c1acb4f
21 changed files with 438 additions and 99 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<SubagentDetail | null> {
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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<Session[]> {
|
||||
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<Session | null> {
|
||||
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<Session | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
// 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<string[]> {
|
||||
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<string | null> {
|
||||
const projectPath = await resolveProjectStorageDir(
|
||||
this.projectsDir,
|
||||
projectId,
|
||||
this.fsProvider
|
||||
);
|
||||
return projectPath ? path.join(projectPath, sessionId, 'subagents') : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
src/main/services/discovery/projectStorageDir.ts
Normal file
32
src/main/services/discovery/projectStorageDir.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ export class SessionParser {
|
|||
* Parse a session JSONL file and return structured data.
|
||||
*/
|
||||
async parseSession(projectId: string, sessionId: string): Promise<ParsedSession> {
|
||||
const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId);
|
||||
const sessionPath =
|
||||
(await this.projectScanner.resolveSessionPath(projectId, sessionId)) ??
|
||||
this.projectScanner.getSessionPath(projectId, sessionId);
|
||||
return this.parseSessionFile(sessionPath);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,25 +147,19 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
|||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !path.extname(trimmed)) {
|
||||
for (const ext of getWindowsExecutableExtensions()) {
|
||||
const candidate = `${trimmed}${ext}`;
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await isExecutable(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.extname(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const ext of getWindowsExecutableExtensions()) {
|
||||
const candidate = `${trimmed}${ext}`;
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ import { isProcessAlive } from '@main/utils/processHealth';
|
|||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||
import {
|
||||
listWindowsProcessTable,
|
||||
listWindowsProcessTableSync,
|
||||
} from '@main/utils/windowsProcessTable';
|
||||
import {
|
||||
AGENT_BLOCK_CLOSE,
|
||||
AGENT_BLOCK_OPEN,
|
||||
|
|
@ -203,6 +207,7 @@ import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
|||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import {
|
||||
commandArgEquals,
|
||||
isStrongRuntimeEvidence,
|
||||
resolveTeamMemberRuntimeLiveness,
|
||||
sanitizeProcessCommandForDiagnostics,
|
||||
|
|
@ -15072,6 +15077,25 @@ export class TeamProvisioningService {
|
|||
}`
|
||||
);
|
||||
}
|
||||
let windowsHostProcessRows: typeof processRows | null = null;
|
||||
let windowsHostProcessTableAvailable = false;
|
||||
const getWindowsHostProcessRows = async (): Promise<typeof processRows> => {
|
||||
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<number>();
|
||||
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) {
|
||||
|
|
|
|||
105
src/main/utils/windowsProcessTable.ts
Normal file
105
src/main/utils/windowsProcessTable.ts
Normal file
|
|
@ -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<WindowsProcessTableRow[]> {
|
||||
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));
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
42
test/main/utils/windowsProcessTable.test.ts
Normal file
42
test/main/utils/windowsProcessTable.test.ts
Normal file
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue