diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index cbfac5cb..4466bfbc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -186,7 +186,11 @@ import { buildProgressAssistantOutput, buildProgressLogsTail, } from './progressPayload'; -import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; +import { + applyDesktopTeammateModeDecisionToEnv, + buildDesktopTeammateModeCliArgs, + resolveDesktopTeammateModeDecision, +} from './runtimeTeammateMode'; import { choosePreferredLaunchSnapshot, clearBootstrapState, @@ -1101,6 +1105,9 @@ function buildRuntimeLaunchWarning( if (env.CLAUDE_CODE_CODEX_BACKEND) { flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`); } + if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') { + flags.push('FORCE_PROCESS_TEAMMATES'); + } const backendPart = backend ? `, backend ${backend}` : ''; const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : ''; const geminiAuth = options?.geminiRuntimeAuth; @@ -1164,6 +1171,7 @@ function logRuntimeLaunchSnapshot( CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null, CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null, CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null, + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null, CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null, CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null, }, @@ -11788,9 +11796,7 @@ export class TeamProvisioningService { let child: ReturnType; shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); - if (teammateModeDecision.forceProcessTeammates) { - shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; - } + applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision); let mcpConfigPath: string; let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; @@ -11854,6 +11860,7 @@ export class TeamProvisioningService { ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), ...providerFastModeArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), + ...buildDesktopTeammateModeCliArgs(teammateModeDecision), ...parseCliArgs(request.extraCliArgs), ...providerArgs, ]); @@ -12863,9 +12870,7 @@ export class TeamProvisioningService { let child: ReturnType; shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); - if (teammateModeDecision.forceProcessTeammates) { - shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; - } + applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision); let mcpConfigPath: string; let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; @@ -12945,6 +12950,7 @@ export class TeamProvisioningService { if (request.worktree) { launchArgs.push('--worktree', request.worktree); } + launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision)); launchArgs.push(...parseCliArgs(request.extraCliArgs)); launchArgs.push(...providerArgs); // When the lead uses a different provider than some teammates (e.g., anthropic lead @@ -15500,8 +15506,9 @@ export class TeamProvisioningService { } const paneIds = [...metadataByMember.values()] + .filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined) .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') - .filter((paneId) => paneId.length > 0); + .filter((paneId) => paneId.length > 0 && !paneId.startsWith('process:')); let paneInfoById = new Map(); if (paneIds.length > 0) { try { diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 648961a0..da393e4c 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -58,22 +58,42 @@ export async function resolveDesktopTeammateModeDecision( }; } - if (explicitMode === 'auto' || explicitMode === 'in-process') { + if (explicitMode === 'auto') { + return { + injectedTeammateMode: null, + forceProcessTeammates: true, + }; + } + + if (explicitMode === 'in-process') { return { injectedTeammateMode: null, forceProcessTeammates: false, }; } - if (!(await isTmuxAvailable())) { - return { - injectedTeammateMode: null, - forceProcessTeammates: false, - }; - } + const tmuxAvailable = await isTmuxAvailable(); return { - injectedTeammateMode: 'tmux', + injectedTeammateMode: tmuxAvailable ? 'tmux' : null, forceProcessTeammates: true, }; } + +export function applyDesktopTeammateModeDecisionToEnv( + env: NodeJS.ProcessEnv, + decision: Pick +): void { + if (decision.forceProcessTeammates) { + env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; + return; + } + + delete env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES; +} + +export function buildDesktopTeammateModeCliArgs( + decision: Pick +): string[] { + return decision.injectedTeammateMode ? ['--teammate-mode', decision.injectedTeammateMode] : []; +} diff --git a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx index 2b4913ab..d29e5785 100644 --- a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx +++ b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx @@ -12,6 +12,7 @@ type TeammateRuntimeIssueReason = | 'mixed-provider' | 'codex-native-runtime' | 'explicit-tmux-mode' + | 'explicit-in-process-mode' | 'opencode-led-mixed-unsupported'; interface RuntimeMemberInput { @@ -176,6 +177,13 @@ export function analyzeTeammateRuntimeCompatibility({ } } + const requiresSeparateProcess = issues.some( + (issue) => issue.reason === 'mixed-provider' || issue.reason === 'codex-native-runtime' + ); + if (explicitTeammateMode === 'in-process' && requiresSeparateProcess) { + issues.push({ reason: 'explicit-in-process-mode' }); + } + if (issues.length === 0) { return { visible: false, @@ -193,7 +201,9 @@ export function analyzeTeammateRuntimeCompatibility({ const hasOpenCodeLeadMixedUnsupported = issues.some( (issue) => issue.reason === 'opencode-led-mixed-unsupported' ); - if (tmuxReady && !hasOpenCodeLeadMixedUnsupported) { + const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode'); + const hasExplicitInProcess = issues.some((issue) => issue.reason === 'explicit-in-process-mode'); + if (!hasOpenCodeLeadMixedUnsupported && !hasExplicitTmux && !hasExplicitInProcess) { return { visible: false, blocksSubmission: false, @@ -206,11 +216,28 @@ export function analyzeTeammateRuntimeCompatibility({ }; } - const checking = !hasOpenCodeLeadMixedUnsupported && tmuxStatusLoading && !tmuxStatus; + if (tmuxReady && hasExplicitTmux && !hasOpenCodeLeadMixedUnsupported && !hasExplicitInProcess) { + return { + visible: false, + blocksSubmission: false, + checking: false, + title: '', + message: '', + details: [], + tmuxDetail: null, + memberWarningById: {}, + }; + } + + const checking = + hasExplicitTmux && + !hasOpenCodeLeadMixedUnsupported && + !hasExplicitInProcess && + tmuxStatusLoading && + !tmuxStatus; const blocksSubmission = true; const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider'); const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime'); - const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode'); const details: string[] = []; const memberWarningById: Record = {}; @@ -241,15 +268,20 @@ export function analyzeTeammateRuntimeCompatibility({ if (hasExplicitTmux) { details.push('Custom CLI args force --teammate-mode tmux.'); } + if (hasExplicitInProcess) { + details.push('Custom CLI args force --teammate-mode in-process.'); + } if (hasOpenCodeLeadMixedUnsupported) { details.push( 'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.' ); + } else if (hasExplicitInProcess) { + details.push( + 'Fix: remove --teammate-mode in-process so teammates can use native process transport.' + ); } else { details.push( - hasCodexNative && !hasMixedProviders - ? 'Fix: install tmux/WSL tmux, use Solo team, or choose a same-provider runtime that supports in-process teammates.' - : 'Fix: install tmux/WSL tmux, use Solo team, or keep every teammate on the same non-Codex-native provider as the lead.' + 'Fix: install tmux/WSL tmux, or remove --teammate-mode tmux so the app can use native process transport.' ); } @@ -260,10 +292,10 @@ export function analyzeTeammateRuntimeCompatibility({ if (issue.reason === 'mixed-provider') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + - `Without tmux, teammates must use the same provider as the ${getProviderLabel(leadProviderId)} lead.`; + `This teammate requires a separate process outside the ${getProviderLabel(leadProviderId)} lead.`; } else if (issue.reason === 'codex-native-runtime') { memberWarningById[issue.memberId] = - `${issue.memberName} uses Codex native. Codex native teammates require a separate process, which currently needs tmux.`; + `${issue.memberName} uses Codex native. Codex native teammates require a separate Codex process.`; } else if (issue.reason === 'opencode-led-mixed-unsupported') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + @@ -276,19 +308,19 @@ export function analyzeTeammateRuntimeCompatibility({ blocksSubmission, checking, title: checking - ? 'Checking tmux runtime for teammate support' + ? 'Checking tmux runtime for explicit teammate mode' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode cannot lead mixed-provider teams' - : hasCodexNative && !hasMixedProviders - ? 'Codex teammates need tmux before they can run' - : 'This team needs tmux before it can run', + : hasExplicitInProcess + ? 'This team cannot use in-process teammates' + : 'tmux is not ready for explicit teammate mode', message: checking - ? 'Some teammates require separate processes. The app is checking whether tmux is available.' + ? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.' - : hasCodexNative && !hasMixedProviders - ? 'The Codex lead can run without tmux, but Codex native teammates cannot use the in-process teammate adapter. They must start as separate Codex processes, and this path currently needs tmux.' - : 'tmux is not ready on this machine. Same-provider in-process teammates can run without tmux, but this team has teammates that require separate processes.', + : hasExplicitInProcess + ? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.' + : 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.', details, tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError), memberWarningById, diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index a478b475..3aaafc4d 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -604,6 +604,41 @@ describe('TeamProvisioningService', () => { }); }); + it('does not send legacy process backend pane markers to tmux liveness lookup', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: 'process:4242', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + } as any); + + await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(listTmuxPaneRuntimeInfoForCurrentPlatform).not.toHaveBeenCalled(); + }); + it('exposes providerBackendId from the live run request when available', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts index 61bae905..74313147 100644 --- a/test/main/services/team/runtimeTeammateMode.test.ts +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -23,15 +23,65 @@ describe('runtimeTeammateMode', () => { expect(decision.injectedTeammateMode).toBe('tmux'); }); - it('keeps fallback mode when tmux runtime is not ready', async () => { + it('uses native process teammates when tmux runtime is not ready', async () => { mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false); const { resolveDesktopTeammateModeDecision } = await import('@main/services/team/runtimeTeammateMode'); const decision = await resolveDesktopTeammateModeDecision(undefined); + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBeNull(); + }); + + it('treats explicit auto mode as automatic process teammate selection without injection', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision('--teammate-mode auto'); + const equalsDecision = await resolveDesktopTeammateModeDecision('--teammate-mode=auto'); + + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBeNull(); + expect(equalsDecision.forceProcessTeammates).toBe(true); + expect(equalsDecision.injectedTeammateMode).toBeNull(); + expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled(); + }); + + it('honors explicit in-process mode as an opt-out from process teammates', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision('--teammate-mode=in-process'); + expect(decision.forceProcessTeammates).toBe(false); expect(decision.injectedTeammateMode).toBeNull(); + expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled(); + }); + + it('removes inherited process fallback env when explicit in-process mode opts out', async () => { + const { applyDesktopTeammateModeDecisionToEnv } = + await import('@main/services/team/runtimeTeammateMode'); + const env = { + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: '1', + }; + + applyDesktopTeammateModeDecisionToEnv(env, { forceProcessTeammates: false }); + + expect(env).not.toHaveProperty('CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES'); + }); + + it('builds injected teammate mode cli args only when a mode was selected', async () => { + const { buildDesktopTeammateModeCliArgs } = + await import('@main/services/team/runtimeTeammateMode'); + + expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: 'tmux' })).toEqual([ + '--teammate-mode', + 'tmux', + ]); + expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: null })).toEqual([]); }); it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => { @@ -44,7 +94,7 @@ describe('runtimeTeammateMode', () => { const firstDecision = await resolveDesktopTeammateModeDecision(undefined); const secondDecision = await resolveDesktopTeammateModeDecision(undefined); - expect(firstDecision.forceProcessTeammates).toBe(false); + expect(firstDecision.forceProcessTeammates).toBe(true); expect(firstDecision.injectedTeammateMode).toBeNull(); expect(secondDecision.forceProcessTeammates).toBe(true); expect(secondDecision.injectedTeammateMode).toBe('tmux'); diff --git a/test/renderer/components/common/TokenUsageDisplay.test.ts b/test/renderer/components/common/TokenUsageDisplay.test.ts index ce584d6d..d92420c2 100644 --- a/test/renderer/components/common/TokenUsageDisplay.test.ts +++ b/test/renderer/components/common/TokenUsageDisplay.test.ts @@ -54,6 +54,10 @@ async function flushReact(): Promise { await Promise.resolve(); } +function withoutNumberGroupSeparators(value: string | null | undefined): string { + return (value ?? '').replace(/[\s,\u00a0\u202f]/g, ''); +} + describe('TokenUsageDisplay', () => { afterEach(() => { document.body.innerHTML = ''; @@ -90,7 +94,7 @@ describe('TokenUsageDisplay', () => { const popover = document.querySelector('[role="tooltip"]'); expect(popover).toBeTruthy(); - expect(popover?.textContent).toContain('2,250'); + expect(withoutNumberGroupSeparators(popover?.textContent)).toContain('2250'); expect(popover?.textContent).toContain('500 (25.0% of prompt input)'); expect(popover?.textContent).not.toContain('of context'); diff --git a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts index db9ba6d9..db38cd37 100644 --- a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts +++ b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts @@ -55,7 +55,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.memberWarningById).toEqual({}); }); - it('blocks mixed-provider teammates when tmux is unavailable', () => { + it('allows mixed-provider teammates through native process transport when tmux is unavailable', () => { const result = analyzeTeammateRuntimeCompatibility({ leadProviderId: 'anthropic', members: [{ id: 'bob', name: 'bob', providerId: 'codex' }], @@ -64,9 +64,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => { tmuxStatusError: null, }); - expect(result.blocksSubmission).toBe(true); - expect(result.details.join('\n')).toContain('Mixed providers'); - expect(result.memberWarningById.bob).toContain('same provider as the Anthropic lead'); + expect(result.blocksSubmission).toBe(false); + expect(result.visible).toBe(false); + expect(result.memberWarningById).toEqual({}); }); it('allows OpenCode secondary-lane teammates without tmux under a non-OpenCode lead', () => { @@ -98,7 +98,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead'); }); - it('blocks same-provider Codex native teammates when tmux is unavailable', () => { + it('allows same-provider Codex native teammates through native process transport when tmux is unavailable', () => { const result = analyzeTeammateRuntimeCompatibility({ leadProviderId: 'codex', leadProviderBackendId: 'codex-native', @@ -108,11 +108,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => { tmuxStatusError: null, }); - expect(result.blocksSubmission).toBe(true); - expect(result.title).toBe('Codex teammates need tmux before they can run'); - expect(result.message).toContain('The Codex lead can run without tmux'); - expect(result.details.join('\n')).toContain('Codex native teammates'); - expect(result.memberWarningById.jack).toContain('Codex native teammates require'); + expect(result.blocksSubmission).toBe(false); + expect(result.visible).toBe(false); + expect(result.memberWarningById).toEqual({}); }); it('allows separate-process teammate requirements when tmux is ready', () => { @@ -155,5 +153,23 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.blocksSubmission).toBe(true); expect(result.details).toContain('Custom CLI args force --teammate-mode tmux.'); + expect(result.message).toContain('native process transport'); + }); + + it('blocks explicit in-process mode when a teammate requires a separate process', () => { + const result = analyzeTeammateRuntimeCompatibility({ + leadProviderId: 'anthropic', + members: [{ id: 'bob', name: 'bob', providerId: 'codex' }], + extraCliArgs: '--teammate-mode=in-process', + tmuxStatus: buildTmuxStatus(true), + tmuxStatusLoading: false, + tmuxStatusError: null, + }); + + expect(result.blocksSubmission).toBe(true); + expect(result.title).toBe('This team cannot use in-process teammates'); + expect(result.details).toContain('Custom CLI args force --teammate-mode in-process.'); + expect(result.message).toContain('native process transport'); + expect(result.memberWarningById.bob).toContain('requires a separate process'); }); });