diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index e22a60a7..c318debc 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter { // Guard: stop() may have been called while awaiting pathExists if (!this.isWatching) return; + // Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch. + // Linux recursive watching expands across the whole team runtime tree and can + // hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with + // processTeamsChange(): team root JSON files plus inbox JSON files only. const registry = new TeamTaskWatchRegistry({ kind: 'teams', rootPath: this.teamsPath, @@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter { // Guard: stop() may have been called while awaiting pathExists if (!this.isWatching) return; + // Tasks share the same shallow registry rule as teams. Keep polling out of + // the normal path here; it is only the known-error fallback below. const registry = new TeamTaskWatchRegistry({ kind: 'tasks', rootPath: this.tasksPath, @@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter { error: unknown, watcher?: CloseableWatcher ): boolean { + // Polling fallback is intentionally narrow. Projects/todos keep their native + // watcher retry behavior, while teams/tasks can switch to scoped polling only + // after known OS watcher-limit or platform errors from Chokidar/fs.watch. if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) { return false; } @@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter { }; runPoll(); + // This is fallback content polling after watcher failure, not the default mode. + // Keep intervals conservative and scoped to the same shallow artifacts as the registry. const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType)); timer.unref(); @@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter { const snapshot = new Map(); const teamEntries = await this.safeReadDir(this.teamsPath); + // Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members, + // runtime, .opencode-runtime, logs, or other deep trees from here. for (const teamEntry of teamEntries) { if (!teamEntry.isDirectory()) { continue; @@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter { const snapshot = new Map(); const teamEntries = await this.safeReadDir(this.tasksPath); + // Keep task fallback scoped to tasks//*.json. Hidden files and nested + // runtime directories are intentionally outside the public team-change surface. for (const teamEntry of teamEntries) { if (!teamEntry.isDirectory()) { continue; @@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter { return; } + // Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit(). + // If a path is emitted by the registry but ignored here, the UI will miss it. + // If a path is added here but not emitted there, Chokidar mode will never see it. if (relative === 'processes.json') { const event: TeamChangeEvent = { type: 'process', teamName, detail: relative }; this.emit('team-change', event); @@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter { return; } + // Keep this in sync with the tasks registry and fallback polling filters: + // only tasks//*.json is a user-visible task event. // Ignore known non-task files in ~/.claude/tasks if ( relative === '.lock' || diff --git a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts index 1efebad6..a54cd3b5 100644 --- a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts +++ b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts @@ -33,6 +33,9 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv( return; } + for (const [key, value] of Object.entries(launchSpec.env ?? {})) { + env[key] = value; + } env[MCP_COMMAND_ENV] = command; env[MCP_ENTRY_ENV] = entry; env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args); diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index da4802cc..2d5c8075 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -1095,6 +1095,7 @@ export class AgentTeamsMcpHttpServer { }; const childEnv = applyAgentTeamsIdentityEnv({ ...process.env, + ...launchSpec.env, AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST, diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 4405fa72..51c64f98 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types'; export interface McpLaunchSpec { command: string; args: string[]; + env?: Record; } export interface McpLaunchSpecResolveProgress { @@ -40,6 +41,7 @@ interface WriteMcpConfigOptions { const MCP_SERVER_NAME = 'agent-teams'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL'; +const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; @@ -58,6 +60,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro function isPackagedApp(): boolean { try { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { app } = require('electron') as typeof import('electron'); return app.isPackaged; } catch { @@ -88,6 +91,25 @@ function getWorkspaceRoot(): string { return process.cwd(); } +function shouldUsePackagedElectronNodeRuntime(): boolean { + return ( + isPackagedApp() && + process.platform === 'linux' && + typeof process.execPath === 'string' && + process.execPath.trim().length > 0 + ); +} + +function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec { + return { + command: process.execPath, + args: [entry], + env: { + [ELECTRON_RUN_AS_NODE_ENV]: '1', + }, + }; +} + function getWorkspaceMcpServerDir(): string { return path.join(getWorkspaceRoot(), 'mcp-server'); } @@ -450,6 +472,14 @@ export async function resolveAgentTeamsMcpLaunchSpec( const packagedEntry = await resolvePackagedServerEntry(options); checked.push(packagedEntry); if (await pathExists(packagedEntry)) { + if (shouldUsePackagedElectronNodeRuntime()) { + emitProgress( + options, + 'electron-node-runtime-found', + 'Using bundled Electron Node runtime...' + ); + return buildPackagedElectronNodeLaunchSpec(packagedEntry); + } return { command: await resolveNodePath(options), args: [packagedEntry], @@ -520,6 +550,7 @@ export class TeamMcpConfigBuilder { args: launchSpec.args, enabled: true, env: { + ...launchSpec.env, [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), ...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}), }, diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 9d444bc3..5c87cce1 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -231,6 +231,20 @@ function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindows }; } +function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null { + const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1]; + if (!nativeTarget) { + return null; + } + + const target = resolveCmdPathTemplate(nativeTarget, launcherDir); + if (!existsSync(target)) { + return null; + } + + return { command: target, argsPrefix: [] }; +} + /** * Some Windows launchers are thin wrappers around a real JS entrypoint. * Running that entrypoint directly with an argv array avoids cmd.exe's @@ -245,7 +259,9 @@ function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher const content = readFileSync(binaryPath, 'utf8'); const launcherDir = path.dirname(binaryPath); return ( - resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir) + resolveGeneratedBunLauncher(content, launcherDir) ?? + resolveNpmNodeShim(content, launcherDir) ?? + resolveNpmNativeShim(content, launcherDir) ); } catch { return null; diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 045b6bea..3ac078fb 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -276,6 +276,25 @@ describe('buildProviderAwareCliEnv', () => { ); }); + it('propagates Agent Teams MCP launch env overrides for OpenCode provider commands', async () => { + resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({ + command: '/opt/Agent Teams AI/agent-teams-ai', + args: ['/app/mcp-server/index.js'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + + const result = await buildProviderAwareCliEnv({ + providerId: 'opencode', + }); + + expect(result.env.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe( + '/opt/Agent Teams AI/agent-teams-ai' + ); + }); + it('preserves explicit local Agent Teams MCP launch env for OpenCode provider commands', async () => { const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index 14539d16..4da2b028 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -127,6 +127,7 @@ describe('AgentTeamsMcpHttpServer', () => { resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], + env: { ELECTRON_RUN_AS_NODE: '1' }, }), allocatePort: async () => 41001, spawnProcess, @@ -167,6 +168,7 @@ describe('AgentTeamsMcpHttpServer', () => { '/mcp', ], expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1', AGENT_TEAMS_MCP_HTTP_PORT: '41001', @@ -521,6 +523,7 @@ describe('AgentTeamsMcpHttpServer', () => { allocatePort, spawnProcess, waitForPort, + canListenOnPort: async () => true, probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })), }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 23f377bf..9374488d 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -60,6 +60,7 @@ vi.mock('@main/utils/shellEnv', async (importOriginal) => { import { clearResolvedNodePathForTests, + resolveAgentTeamsMcpLaunchSpec, TeamMcpConfigBuilder, } from '@main/services/team/TeamMcpConfigBuilder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; @@ -354,6 +355,53 @@ describe('TeamMcpConfigBuilder', () => { expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); + it('uses the packaged Electron Node runtime for Linux packaged MCP launches', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); + const electronBinary = '/opt/Agent Teams AI/agent-teams-ai'; + setPackagedMode(true, '3.0.0'); + const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); + createdDirs.push(resourcesDir); + createPackagedServerBundle(resourcesDir, '// packaged linux server'); + setResourcesPath(resourcesDir); + + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: electronBinary, + configurable: true, + writable: true, + }); + + try { + const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + const server = readGeneratedServer(configPath); + const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js'); + + expect(launchSpec).toEqual({ + command: electronBinary, + args: [expectedEntry], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + expect(server?.command).toBe(electronBinary); + expect(server?.args).toEqual([expectedEntry]); + expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); + expect(hoisted.execCliMock).not.toHaveBeenCalled(); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + if (execPathDescriptor) { + Object.defineProperty(process, 'execPath', execPathDescriptor); + } + } + }); + it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => { mockBuiltWorkspaceEntryAvailable(); const previousNodeBinary = process.env.NODE_BINARY; diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 65ed6dd2..e26a6d01 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -102,6 +102,36 @@ function createExtensionlessNpmNodeLauncher(): { return { dir, launcher, target }; } +function createNpmNativeExeLauncher(): { + dir: string; + launcher: string; + target: string; +} { + const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-native-launcher-')); + const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin'); + mkdirSync(targetDir, { recursive: true }); + const target = path.join(targetDir, 'opencode.exe'); + writeFileSync(target, '', 'utf8'); + const launcher = path.join(dir, 'opencode.cmd'); + writeFileSync( + launcher, + [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*', + '', + ].join('\r\n'), + 'utf8' + ); + return { dir, launcher, target }; +} + describe('cli child process helpers', () => { beforeEach(() => { vi.resetAllMocks(); @@ -220,6 +250,25 @@ describe('cli child process helpers', () => { } }); + it('runs npm native exe cmd launchers directly', () => { + setPlatform('win32'); + const fake = new EventEmitter() as ReturnType; + const spawnMock = child.spawn as unknown as Mock; + spawnMock.mockReturnValue(fake); + const { dir, launcher, target } = createNpmNativeExeLauncher(); + try { + const result = spawnCli(launcher, ['serve', '--hostname', '127.0.0.1']); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe(target); + expect(spawnMock.mock.calls[0][1]).toEqual(['serve', '--hostname', '127.0.0.1']); + expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell'); + expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true }); + expect(result).toBe(fake); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('uses shell directly when path contains non-ASCII on windows', () => { setPlatform('win32'); const fake = {} as any;